diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1af0336 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,26 @@ +## Description + + +## Type of Change +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Test improvement + +## Testing +- [ ] Unit tests pass locally +- [ ] New tests have been added for new functionality +- [ ] Existing tests have been updated if needed + +## Checklist +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code where necessary (following the no-comments rule) +- [ ] My changes generate no new warnings +- [ ] Any dependent changes have been merged and published + +## Screenshots (if applicable) + + +## Additional Notes + \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..76db2cc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,131 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + lint: + name: SwiftLint + runs-on: macos-15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install SwiftLint + run: brew install swiftlint + + - name: Run SwiftLint + run: | + cd Recap + swiftlint --strict --reporter github-actions-logging + + build: + name: Build + runs-on: macos-15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Cache DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('Recap.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + + - name: Resolve Package Dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project Recap.xcodeproj \ + -scheme Recap + + - name: Build Debug + run: | + xcodebuild build \ + -project Recap.xcodeproj \ + -scheme Recap \ + -configuration Debug \ + -destination 'platform=macOS' \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO + + - name: Build Release + run: | + xcodebuild build \ + -project Recap.xcodeproj \ + -scheme Recap \ + -configuration Release \ + -destination 'platform=macOS' \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO + + test: + name: Test + runs-on: macos-15 + needs: build + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Cache DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('Recap.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + + - name: Resolve Package Dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project Recap.xcodeproj \ + -scheme Recap + + - name: Run Tests with Coverage + run: | + xcodebuild test \ + -project Recap.xcodeproj \ + -scheme Recap \ + -destination 'platform=macOS' \ + -resultBundlePath TestResults.xcresult \ + -enableCodeCoverage YES \ + -only-testing:RecapTests \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO + + - name: Generate Coverage Report + run: | + xcrun xccov view --report --json TestResults.xcresult > coverage.json + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: TestResults.xcresult + + - name: Upload Coverage Reports + uses: codecov/codecov-action@v5 + with: + file: coverage.json + flags: unittests + name: recap-coverage + fail_ci_if_error: false \ No newline at end of file diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml new file mode 100644 index 0000000..b296213 --- /dev/null +++ b/.github/workflows/pr-tests.yml @@ -0,0 +1,74 @@ +name: PR Tests + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + test: + name: Test PR + runs-on: macos-15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Cache DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('Recap.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + + - name: Resolve Package Dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project Recap.xcodeproj \ + -scheme Recap + + - name: Build and Test + run: | + xcodebuild test \ + -project Recap.xcodeproj \ + -scheme Recap \ + -destination 'platform=macOS' \ + -resultBundlePath TestResults.xcresult \ + -only-testing:RecapTests \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO + + - name: Comment PR on Failure + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '❌ Tests failed. Please check the [workflow logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}).' + }) + + - name: Comment PR on Success + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '✅ All tests passed!' + }) \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..473bc41 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,67 @@ +name: Test Suite + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + workflow_dispatch: + +jobs: + test: + name: Run Tests + runs-on: macos-15 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Cache DerivedData + uses: actions/cache@v4 + with: + path: ~/Library/Developer/Xcode/DerivedData + key: ${{ runner.os }}-deriveddata-${{ hashFiles('Recap.xcodeproj/project.pbxproj') }} + restore-keys: | + ${{ runner.os }}-deriveddata- + + - name: Resolve Package Dependencies + run: | + xcodebuild -resolvePackageDependencies \ + -project Recap.xcodeproj \ + -scheme Recap + + - name: Build and Test + run: | + xcodebuild test \ + -project Recap.xcodeproj \ + -scheme Recap \ + -destination 'platform=macOS' \ + -resultBundlePath TestResults.xcresult \ + -enableCodeCoverage YES \ + -only-testing:RecapTests \ + -skipMacroValidation \ + CODE_SIGNING_ALLOWED=NO + + - name: Upload Test Results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: TestResults.xcresult + + - name: Generate Coverage Report + run: | + xcrun xccov view --report --json TestResults.xcresult > coverage.json + + - name: Upload Coverage + uses: codecov/codecov-action@v5 + with: + file: coverage.json + flags: unittests + name: recap-coverage + fail_ci_if_error: false \ No newline at end of file diff --git a/Recap.xcodeproj/project.pbxproj b/Recap.xcodeproj/project.pbxproj index 3a81b1c..5f9c033 100644 --- a/Recap.xcodeproj/project.pbxproj +++ b/Recap.xcodeproj/project.pbxproj @@ -42,9 +42,16 @@ A7C35B1B2E3DFE1D00F9261F /* Exceptions for "Recap" folder in "RecapTests" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Audio/Models/AudioProcess.swift, + Audio/Models/AudioProcessGroup.swift, + Audio/Processing/Detection/AudioProcessControllerType.swift, + Components/Buttons/PillButton.swift, + Components/Cards/ActionableWarningCard.swift, DataModels/RecapDataModel.xcdatamodeld, "Helpers/Colors/Color+Extension.swift", - Helpers/UIConstants.swift, + Helpers/Constants/AppConstants.swift, + Helpers/Constants/UIConstants.swift, + Helpers/MeetingDetection/MeetingPatternMatcher.swift, Repositories/Models/LLMProvider.swift, Repositories/Models/RecordingInfo.swift, Repositories/Models/UserPreferencesInfo.swift, @@ -54,6 +61,12 @@ Repositories/UserPreferences/UserPreferencesRepositoryType.swift, Services/CoreData/CoreDataManagerType.swift, Services/LLM/Core/LLMError.swift, + Services/MeetingDetection/Core/MeetingDetectionService.swift, + Services/MeetingDetection/Core/MeetingDetectionServiceType.swift, + Services/MeetingDetection/Detectors/GoogleMeetDetector.swift, + Services/MeetingDetection/Detectors/MeetingDetectorType.swift, + Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift, + Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift, Services/Processing/Models/ProcessingError.swift, Services/Processing/Models/ProcessingResult.swift, Services/Processing/Models/ProcessingState.swift, @@ -65,10 +78,16 @@ Services/Summarization/Models/SummarizationResult.swift, Services/Summarization/SummarizationServiceType.swift, Services/Transcription/TranscriptionServiceType.swift, - Views/Summary/Components/ProcessingProgressBar.swift, - Views/Summary/Components/ProcessingStatesCard.swift, - Views/Summary/ViewModel/SummaryViewModel.swift, - Views/Summary/ViewModel/SummaryViewModelType.swift, + Services/Warnings/WarningManagerType.swift, + UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift, + UseCases/Settings/Components/Reusable/CustomToggle.swift, + UseCases/Settings/Components/SettingsCard.swift, + UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift, + UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift, + UseCases/Summary/Components/ProcessingProgressBar.swift, + UseCases/Summary/Components/ProcessingStatesCard.swift, + UseCases/Summary/ViewModel/SummaryViewModel.swift, + UseCases/Summary/ViewModel/SummaryViewModelType.swift, ); target = A721065F2E30165B0073C515 /* RecapTests */; }; @@ -515,7 +534,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EY7EQX6JC5; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.RecapTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -534,7 +553,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EY7EQX6JC5; GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 15.5; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.RecapTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -551,6 +570,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EY7EQX6JC5; GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.RecapUITests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -567,6 +587,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = EY7EQX6JC5; GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 15.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = dev.rawa.RecapUITests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/1024.png b/Recap/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index c3b3bba..0000000 Binary files a/Recap/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/128.png b/Recap/Assets.xcassets/AppIcon.appiconset/128.png deleted file mode 100644 index 24f9e03..0000000 Binary files a/Recap/Assets.xcassets/AppIcon.appiconset/128.png and /dev/null differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/16.png b/Recap/Assets.xcassets/AppIcon.appiconset/16.png deleted file mode 100644 index 2d0150f..0000000 Binary files a/Recap/Assets.xcassets/AppIcon.appiconset/16.png and /dev/null differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/256.png b/Recap/Assets.xcassets/AppIcon.appiconset/256.png deleted file mode 100644 index cbed3a0..0000000 Binary files a/Recap/Assets.xcassets/AppIcon.appiconset/256.png and /dev/null differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/32.png b/Recap/Assets.xcassets/AppIcon.appiconset/32.png deleted file mode 100644 index 521df72..0000000 Binary files a/Recap/Assets.xcassets/AppIcon.appiconset/32.png and /dev/null differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/512.png b/Recap/Assets.xcassets/AppIcon.appiconset/512.png deleted file mode 100644 index 175e3f7..0000000 Binary files a/Recap/Assets.xcassets/AppIcon.appiconset/512.png and /dev/null differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/64.png b/Recap/Assets.xcassets/AppIcon.appiconset/64.png deleted file mode 100644 index aab3070..0000000 Binary files a/Recap/Assets.xcassets/AppIcon.appiconset/64.png and /dev/null differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/Contents.json b/Recap/Assets.xcassets/AppIcon.appiconset/Contents.json index d4e03ef..442dd5e 100644 --- a/Recap/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Recap/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,61 +1,67 @@ { "images" : [ { - "filename" : "16.png", + "filename" : "appstore1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "filename" : "mac16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "32.png", + "filename" : "mac32.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "32.png", + "filename" : "mac32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "64.png", + "filename" : "mac64.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "128.png", + "filename" : "mac128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "256.png", + "filename" : "mac256.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "256.png", + "filename" : "mac256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "512.png", + "filename" : "mac512.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "512.png", + "filename" : "mac512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024.png", + "filename" : "mac1024.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/appstore1024.png b/Recap/Assets.xcassets/AppIcon.appiconset/appstore1024.png new file mode 100644 index 0000000..75a9c71 Binary files /dev/null and b/Recap/Assets.xcassets/AppIcon.appiconset/appstore1024.png differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/mac1024.png b/Recap/Assets.xcassets/AppIcon.appiconset/mac1024.png new file mode 100644 index 0000000..0b616dd Binary files /dev/null and b/Recap/Assets.xcassets/AppIcon.appiconset/mac1024.png differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/mac128.png b/Recap/Assets.xcassets/AppIcon.appiconset/mac128.png new file mode 100644 index 0000000..50464ae Binary files /dev/null and b/Recap/Assets.xcassets/AppIcon.appiconset/mac128.png differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/mac16.png b/Recap/Assets.xcassets/AppIcon.appiconset/mac16.png new file mode 100644 index 0000000..53d27cd Binary files /dev/null and b/Recap/Assets.xcassets/AppIcon.appiconset/mac16.png differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/mac256.png b/Recap/Assets.xcassets/AppIcon.appiconset/mac256.png new file mode 100644 index 0000000..45f4353 Binary files /dev/null and b/Recap/Assets.xcassets/AppIcon.appiconset/mac256.png differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/mac32.png b/Recap/Assets.xcassets/AppIcon.appiconset/mac32.png new file mode 100644 index 0000000..e859b35 Binary files /dev/null and b/Recap/Assets.xcassets/AppIcon.appiconset/mac32.png differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/mac512.png b/Recap/Assets.xcassets/AppIcon.appiconset/mac512.png new file mode 100644 index 0000000..612eec8 Binary files /dev/null and b/Recap/Assets.xcassets/AppIcon.appiconset/mac512.png differ diff --git a/Recap/Assets.xcassets/AppIcon.appiconset/mac64.png b/Recap/Assets.xcassets/AppIcon.appiconset/mac64.png new file mode 100644 index 0000000..94cfd31 Binary files /dev/null and b/Recap/Assets.xcassets/AppIcon.appiconset/mac64.png differ diff --git a/Recap/Audio/Capture/MicrophoneCapture.swift b/Recap/Audio/Capture/MicrophoneCapture.swift index 312ca49..b0c1c8f 100644 --- a/Recap/Audio/Capture/MicrophoneCapture.swift +++ b/Recap/Audio/Capture/MicrophoneCapture.swift @@ -13,7 +13,7 @@ import Combine import OSLog final class MicrophoneCapture: MicrophoneCaptureType { - let logger = Logger(subsystem: "com.recap.audio", category: String(describing: MicrophoneCapture.self)) + let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: MicrophoneCapture.self)) var audioEngine: AVAudioEngine? var audioFile: AVAudioFile? diff --git a/Recap/Audio/Capture/Tap/ProcessTap.swift b/Recap/Audio/Capture/Tap/ProcessTap.swift index 857b13f..c3df345 100644 --- a/Recap/Audio/Capture/Tap/ProcessTap.swift +++ b/Recap/Audio/Capture/Tap/ProcessTap.swift @@ -24,7 +24,7 @@ final class ProcessTap: ObservableObject { init(process: AudioProcess, muteWhenRunning: Bool = false) { self.process = process self.muteWhenRunning = muteWhenRunning - self.logger = Logger(subsystem: "com.recap.audio", category: "\(String(describing: ProcessTap.self))(\(process.name))") + self.logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "\(String(describing: ProcessTap.self))(\(process.name))") } @ObservationIgnored @@ -184,7 +184,7 @@ final class ProcessTapRecorder: ObservableObject { self.process = tap.process self.fileURL = fileURL self._tap = tap - self.logger = Logger(subsystem: "com.recap.audio", category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))") + self.logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "\(String(describing: ProcessTapRecorder.self))(\(fileURL.lastPathComponent))") } private var tap: ProcessTap { diff --git a/Recap/Audio/Core/Utils/AudioProcessFactory.swift b/Recap/Audio/Core/AudioProcessFactory.swift similarity index 100% rename from Recap/Audio/Core/Utils/AudioProcessFactory.swift rename to Recap/Audio/Core/AudioProcessFactory.swift diff --git a/Recap/Audio/Core/CoreAudioUtils.swift b/Recap/Audio/Core/Utils/CoreAudioUtils.swift similarity index 100% rename from Recap/Audio/Core/CoreAudioUtils.swift rename to Recap/Audio/Core/Utils/CoreAudioUtils.swift diff --git a/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift b/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift index a90e73a..0ba6b0e 100644 --- a/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift +++ b/Recap/Audio/Processing/AudioRecordingCoordinator/AudioRecordingCoordinator.swift @@ -3,7 +3,7 @@ import AudioToolbox import OSLog final class AudioRecordingCoordinator: AudioRecordingCoordinatorType { - private let logger = Logger(subsystem: "com.recap.audio", category: String(describing: AudioRecordingCoordinator.self)) + private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: AudioRecordingCoordinator.self)) private let configuration: RecordingConfiguration private let microphoneCapture: MicrophoneCapture? diff --git a/Recap/Audio/Processing/Detection/AudioProcessController.swift b/Recap/Audio/Processing/Detection/AudioProcessController.swift index 8991050..a6d5211 100644 --- a/Recap/Audio/Processing/Detection/AudioProcessController.swift +++ b/Recap/Audio/Processing/Detection/AudioProcessController.swift @@ -6,7 +6,7 @@ import Combine @MainActor final class AudioProcessController: AudioProcessControllerType { - private let logger = Logger(subsystem: "com.recap.audio", category: String(describing: AudioProcessController.self)) + private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: AudioProcessController.self)) private let detectionService: AudioProcessDetectionServiceType private var cancellables = Set() diff --git a/Recap/Audio/Processing/Detection/AudioProcessControllerType.swift b/Recap/Audio/Processing/Detection/AudioProcessControllerType.swift index 9c7e8b4..dca3eb8 100644 --- a/Recap/Audio/Processing/Detection/AudioProcessControllerType.swift +++ b/Recap/Audio/Processing/Detection/AudioProcessControllerType.swift @@ -1,6 +1,12 @@ import Foundation import Combine +#if MOCKING +import Mockable +#endif +#if MOCKING +@Mockable +#endif protocol AudioProcessControllerType: ObservableObject { var processes: [AudioProcess] { get } var processGroups: [AudioProcessGroup] { get } diff --git a/Recap/Audio/Processing/Detection/AudioProcessDetectionService.swift b/Recap/Audio/Processing/Detection/AudioProcessDetectionService.swift index 58996a9..4ab83df 100644 --- a/Recap/Audio/Processing/Detection/AudioProcessDetectionService.swift +++ b/Recap/Audio/Processing/Detection/AudioProcessDetectionService.swift @@ -8,7 +8,7 @@ protocol AudioProcessDetectionServiceType { } final class AudioProcessDetectionService: AudioProcessDetectionServiceType { - private let logger = Logger(subsystem: "com.recap.audio", category: String(describing: AudioProcessDetectionService.self)) + private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: AudioProcessDetectionService.self)) func detectActiveProcesses(from apps: [NSRunningApplication]) throws -> [AudioProcess] { let objectIdentifiers = try AudioObjectID.readProcessList() diff --git a/Recap/Audio/Processing/Detection/MeetingAppDetectionService.swift b/Recap/Audio/Processing/Detection/MeetingAppDetectionService.swift index 25692c2..faf4468 100644 --- a/Recap/Audio/Processing/Detection/MeetingAppDetectionService.swift +++ b/Recap/Audio/Processing/Detection/MeetingAppDetectionService.swift @@ -6,13 +6,13 @@ protocol MeetingAppDetecting { } final class MeetingAppDetectionService: MeetingAppDetecting { - private var processController: AudioProcessControllerType? + private var processController: (any AudioProcessControllerType)? - init(processController: AudioProcessControllerType?) { + init(processController: (any AudioProcessControllerType)?) { self.processController = processController } - func setProcessController(_ controller: AudioProcessControllerType) { + func setProcessController(_ controller: any AudioProcessControllerType) { self.processController = controller } @@ -25,4 +25,4 @@ final class MeetingAppDetectionService: MeetingAppDetecting { guard let processController = processController else { return [] } return await MainActor.run { processController.processes } } -} \ No newline at end of file +} diff --git a/Recap/Audio/Processing/RecordingCoordinator.swift b/Recap/Audio/Processing/RecordingCoordinator.swift index 266ed2b..84d6436 100644 --- a/Recap/Audio/Processing/RecordingCoordinator.swift +++ b/Recap/Audio/Processing/RecordingCoordinator.swift @@ -3,7 +3,7 @@ import AVFoundation import OSLog final class RecordingCoordinator: ObservableObject { - private let logger = Logger(subsystem: "com.recap.audio", category: String(describing: RecordingCoordinator.self)) + private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: RecordingCoordinator.self)) private(set) var state: RecordingState = .idle private(set) var detectedMeetingApps: [AudioProcess] = [] diff --git a/Recap/Audio/Processing/RecordingCoordinatorFactory.swift b/Recap/Audio/Processing/RecordingCoordinatorFactory.swift deleted file mode 100644 index cdf6e95..0000000 --- a/Recap/Audio/Processing/RecordingCoordinatorFactory.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -extension RecordingCoordinator { - static func createDefault() -> RecordingCoordinator { - let microphoneCapture = MicrophoneCapture() - let coordinator = RecordingCoordinator( - appDetectionService: MeetingAppDetectionService(processController: nil), - sessionManager: RecordingSessionManager(microphoneCapture: microphoneCapture), - fileManager: RecordingFileManager(), - microphoneCapture: microphoneCapture - ) - coordinator.setupProcessController() - return coordinator - } -} diff --git a/Recap/Audio/Processing/Session/RecordingSessionManager.swift b/Recap/Audio/Processing/Session/RecordingSessionManager.swift index f41f99d..68b1b30 100644 --- a/Recap/Audio/Processing/Session/RecordingSessionManager.swift +++ b/Recap/Audio/Processing/Session/RecordingSessionManager.swift @@ -6,7 +6,7 @@ protocol RecordingSessionManaging { } final class RecordingSessionManager: RecordingSessionManaging { - private let logger = Logger(subsystem: "com.recap.audio", category: String(describing: RecordingSessionManager.self)) + private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: RecordingSessionManager.self)) private let microphoneCapture: MicrophoneCapture init(microphoneCapture: MicrophoneCapture) { diff --git a/Recap/Components/Cards/ActionableWarningCard.swift b/Recap/Components/Cards/ActionableWarningCard.swift new file mode 100644 index 0000000..bc38dee --- /dev/null +++ b/Recap/Components/Cards/ActionableWarningCard.swift @@ -0,0 +1,133 @@ +import SwiftUI + +struct ActionableWarningCard: View { + let warning: WarningItem + let containerWidth: CGFloat + let buttonText: String? + let buttonAction: (() -> Void)? + let footerText: String? + + init( + warning: WarningItem, + containerWidth: CGFloat, + buttonText: String? = nil, + buttonAction: (() -> Void)? = nil, + footerText: String? = nil + ) { + self.warning = warning + self.containerWidth = containerWidth + self.buttonText = buttonText + self.buttonAction = buttonAction + self.footerText = footerText + } + + var body: some View { + let severityColor = Color(hex: warning.severity.color) + + let cardBackground = LinearGradient( + gradient: Gradient(stops: [ + .init(color: severityColor.opacity(0.1), location: 0), + .init(color: severityColor.opacity(0.05), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + let cardBorder = LinearGradient( + gradient: Gradient(stops: [ + .init(color: severityColor.opacity(0.3), location: 0), + .init(color: severityColor.opacity(0.2), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ) + + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + Image(systemName: warning.icon) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(severityColor) + + Text(warning.title) + .font(UIConstants.Typography.cardTitle) + .foregroundColor(UIConstants.Colors.textPrimary) + + Spacer() + } + + VStack(alignment: .leading, spacing: 8) { + Text(warning.message) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.leading) + + if let footerText = footerText { + Text(footerText) + .font(.system(size: 9)) + .foregroundColor(UIConstants.Colors.textSecondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } + + if let buttonText = buttonText, let buttonAction = buttonAction { + HStack { + PillButton( + text: buttonText, + icon: "gear" + ) { + buttonAction() + } + Spacer() + } + } + } + .padding(.horizontal, UIConstants.Spacing.cardPadding + 4) + .padding(.vertical, UIConstants.Spacing.cardPadding) + .frame(width: UIConstants.Layout.fullCardWidth(containerWidth: containerWidth)) + .background( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .fill(cardBackground) + .overlay( + RoundedRectangle(cornerRadius: UIConstants.Sizing.cornerRadius) + .stroke(cardBorder, lineWidth: UIConstants.Sizing.borderWidth) + ) + ) + } +} + +#Preview { + GeometryReader { geometry in + VStack(spacing: 16) { + ActionableWarningCard( + warning: WarningItem( + id: "screen-recording", + title: "Permission Required", + message: "Screen Recording permission needed to detect meeting windows", + icon: "exclamationmark.shield", + severity: .warning + ), + containerWidth: geometry.size.width, + buttonText: "Open System Settings", + buttonAction: { + print("Button tapped") + }, + footerText: "This permission allows Recap to read window titles only. No screen content is captured or recorded." + ) + + ActionableWarningCard( + warning: WarningItem( + id: "network", + title: "Connection Issue", + message: "Unable to connect to the service. Check your network connection and try again.", + icon: "network.slash", + severity: .error + ), + containerWidth: geometry.size.width + ) + } + .padding(20) + } + .frame(width: 500, height: 400) + .background(UIConstants.Gradients.backgroundGradient) +} diff --git a/Recap/Helpers/Constants/AppConstants.swift b/Recap/Helpers/Constants/AppConstants.swift new file mode 100644 index 0000000..74b7b61 --- /dev/null +++ b/Recap/Helpers/Constants/AppConstants.swift @@ -0,0 +1,7 @@ +import Foundation + +struct AppConstants { + struct Logging { + static let subsystem = "com.recap.audio" + } +} \ No newline at end of file diff --git a/Recap/Helpers/UIConstants.swift b/Recap/Helpers/Constants/UIConstants.swift similarity index 100% rename from Recap/Helpers/UIConstants.swift rename to Recap/Helpers/Constants/UIConstants.swift diff --git a/Recap/Helpers/MeetingDetection/MeetingPatternMatcher.swift b/Recap/Helpers/MeetingDetection/MeetingPatternMatcher.swift new file mode 100644 index 0000000..09aadf5 --- /dev/null +++ b/Recap/Helpers/MeetingDetection/MeetingPatternMatcher.swift @@ -0,0 +1,95 @@ +import Foundation + +struct MeetingPattern { + let keyword: String + let confidence: MeetingDetectionResult.MeetingConfidence + let caseSensitive: Bool + let excludePatterns: [String] + + init( + keyword: String, + confidence: MeetingDetectionResult.MeetingConfidence, + caseSensitive: Bool = false, + excludePatterns: [String] = [] + ) { + self.keyword = keyword + self.confidence = confidence + self.caseSensitive = caseSensitive + self.excludePatterns = excludePatterns + } +} + +final class MeetingPatternMatcher { + private let patterns: [MeetingPattern] + + init(patterns: [MeetingPattern]) { + self.patterns = patterns.sorted { $0.confidence.rawValue > $1.confidence.rawValue } + } + + func findBestMatch(in title: String) -> MeetingDetectionResult.MeetingConfidence? { + let processedTitle = title.lowercased() + + for pattern in patterns { + let searchText = pattern.caseSensitive ? title : processedTitle + let searchKeyword = pattern.caseSensitive ? pattern.keyword : pattern.keyword.lowercased() + + if searchText.contains(searchKeyword) { + let shouldExclude = pattern.excludePatterns.contains { excludePattern in + processedTitle.contains(excludePattern.lowercased()) + } + + if !shouldExclude { + return pattern.confidence + } + } + } + + return nil + } +} + +extension MeetingPatternMatcher { + private static var commonMeetingPatterns: [MeetingPattern] { + return [ + MeetingPattern(keyword: "refinement", confidence: .high), + MeetingPattern(keyword: "daily", confidence: .high), + MeetingPattern(keyword: "sync", confidence: .high), + MeetingPattern(keyword: "retro", confidence: .high), + MeetingPattern(keyword: "retrospective", confidence: .high), + MeetingPattern(keyword: "meeting", confidence: .medium), + MeetingPattern(keyword: "call", confidence: .medium) + ] + } + + static var teamsPatterns: [MeetingPattern] { + return [ + MeetingPattern(keyword: "microsoft teams meeting", confidence: .high), + MeetingPattern(keyword: "teams meeting", confidence: .high), + MeetingPattern(keyword: "meeting in \"", confidence: .high), + MeetingPattern(keyword: "call with", confidence: .high), + MeetingPattern( + keyword: "| Microsoft Teams", + confidence: .high, + caseSensitive: true, + excludePatterns: ["chat", "activity", "microsoft teams"] + ), + MeetingPattern(keyword: "screen sharing", confidence: .medium) + ] + commonMeetingPatterns + } + + static var zoomPatterns: [MeetingPattern] { + return [ + MeetingPattern(keyword: "zoom meeting", confidence: .high), + MeetingPattern(keyword: "zoom webinar", confidence: .high), + MeetingPattern(keyword: "screen share", confidence: .medium) + ] + commonMeetingPatterns + } + + static var googleMeetPatterns: [MeetingPattern] { + return [ + MeetingPattern(keyword: "meet.google.com", confidence: .high), + MeetingPattern(keyword: "google meet", confidence: .high), + MeetingPattern(keyword: "meet -", confidence: .medium) + ] + commonMeetingPatterns + } +} \ No newline at end of file diff --git a/Recap/Info.plist b/Recap/Info.plist index 298aa98..971f423 100644 --- a/Recap/Info.plist +++ b/Recap/Info.plist @@ -3,6 +3,8 @@ NSAudioCaptureUsageDescription - YO + Recap needs access to your microphone to record audio during meetings. Audio is only captured when you choose to record a meeting. + NSScreenCaptureUsageDescription + Recap needs Screen Recording permission to detect when Microsoft Teams meetings start. This allows automatic recording of your meetings. No screen content is captured or stored - only window titles are checked to identify active meetings. diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift index b45108b..00fae28 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+Settings.swift @@ -6,7 +6,9 @@ extension MenuBarPanelManager { let generalSettingsViewModel = dependencyContainer.createGeneralSettingsViewModel() let contentView = SettingsView( whisperModelsViewModel: whisperModelsViewModel, - generalSettingsViewModel: generalSettingsViewModel + generalSettingsViewModel: generalSettingsViewModel, + meetingDetectionService: dependencyContainer.meetingDetectionService, + userPreferencesRepository: dependencyContainer.userPreferencesRepository ) { [weak self] in self?.hideSettingsPanel() } diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager.swift b/Recap/MenuBar/Manager/MenuBarPanelManager.swift index 4bc3980..de13ab8 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager.swift @@ -93,6 +93,14 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { } } + func showMainPanel() { + showPanel() + } + + func hideMainPanel() { + hidePanel() + } + private func hidePanel() { guard let panel = panel else { return } diff --git a/Recap/RecapApp.swift b/Recap/RecapApp.swift index 65ce8e6..159e685 100644 --- a/Recap/RecapApp.swift +++ b/Recap/RecapApp.swift @@ -7,6 +7,7 @@ import SwiftUI import AppKit +import UserNotifications @main struct RecapApp: App { @@ -28,6 +29,23 @@ class AppDelegate: NSObject, NSApplicationDelegate { Task { @MainActor in dependencyContainer = DependencyContainer() panelManager = dependencyContainer?.createMenuBarPanelManager() + + UNUserNotificationCenter.current().delegate = self } } } + +extension AppDelegate: UNUserNotificationCenterDelegate { + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + Task { @MainActor in + if response.notification.request.content.userInfo["action"] as? String == "open_app" { + panelManager?.showMainPanel() + } + } + completionHandler() + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .sound]) + } +} diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Coordinators.swift b/Recap/Services/DependencyContainer/DependencyContainer+Coordinators.swift index 682414f..f125434 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer+Coordinators.swift +++ b/Recap/Services/DependencyContainer/DependencyContainer+Coordinators.swift @@ -3,7 +3,14 @@ import Foundation extension DependencyContainer { func makeRecordingCoordinator() -> RecordingCoordinator { - RecordingCoordinator.createDefault() + let coordinator = RecordingCoordinator( + appDetectionService: meetingAppDetectionService, + sessionManager: recordingSessionManager, + fileManager: recordingFileManager, + microphoneCapture: microphoneCapture + ) + coordinator.setupProcessController() + return coordinator } func makeProcessingCoordinator() -> ProcessingCoordinator { @@ -21,4 +28,8 @@ extension DependencyContainer { llmService: llmService ) } + + func makeAppSelectionCoordinator() -> AppSelectionCoordinatorType { + AppSelectionCoordinator(appSelectionViewModel: appSelectionViewModel) + } } \ No newline at end of file diff --git a/Recap/Services/DependencyContainer/DependencyContainer+Services.swift b/Recap/Services/DependencyContainer/DependencyContainer+Services.swift index a0ccfd0..9025e09 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer+Services.swift +++ b/Recap/Services/DependencyContainer/DependencyContainer+Services.swift @@ -16,4 +16,27 @@ extension DependencyContainer { func makeTranscriptionService() -> TranscriptionServiceType { TranscriptionService(whisperModelRepository: whisperModelRepository) } + + func makeMeetingDetectionService() -> MeetingDetectionServiceType { + MeetingDetectionService(audioProcessController: audioProcessController) + } + + func makeMeetingAppDetectionService() -> MeetingAppDetecting { + MeetingAppDetectionService(processController: audioProcessController) + } + + func makeRecordingSessionManager() -> RecordingSessionManaging { + guard let micCapture = microphoneCapture as? MicrophoneCapture else { + fatalError("microphoneCapture is not of type MicrophoneCapture") + } + return RecordingSessionManager(microphoneCapture: micCapture) + } + + func makeMicrophoneCapture() -> MicrophoneCaptureType { + MicrophoneCapture() + } + + func makeNotificationService() -> NotificationServiceType { + NotificationService() + } } \ No newline at end of file diff --git a/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift b/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift index e5195a1..28f6e76 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift +++ b/Recap/Services/DependencyContainer/DependencyContainer+ViewModels.swift @@ -22,4 +22,11 @@ extension DependencyContainer { warningManager: warningManager ) } + + func makeMeetingDetectionSettingsViewModel() -> MeetingDetectionSettingsViewModel { + MeetingDetectionSettingsViewModel( + detectionService: meetingDetectionService, + userPreferencesRepository: userPreferencesRepository + ) + } } \ No newline at end of file diff --git a/Recap/Services/DependencyContainer/DependencyContainer.swift b/Recap/Services/DependencyContainer/DependencyContainer.swift index 8f9515d..68cb91f 100644 --- a/Recap/Services/DependencyContainer/DependencyContainer.swift +++ b/Recap/Services/DependencyContainer/DependencyContainer.swift @@ -23,6 +23,12 @@ final class DependencyContainer { lazy var transcriptionService: TranscriptionServiceType = makeTranscriptionService() lazy var warningManager: any WarningManagerType = makeWarningManager() lazy var providerWarningCoordinator: ProviderWarningCoordinator = makeProviderWarningCoordinator() + lazy var meetingDetectionService: MeetingDetectionServiceType = makeMeetingDetectionService() + lazy var meetingAppDetectionService: MeetingAppDetecting = makeMeetingAppDetectionService() + lazy var recordingSessionManager: RecordingSessionManaging = makeRecordingSessionManager() + lazy var microphoneCapture: MicrophoneCaptureType = makeMicrophoneCapture() + lazy var notificationService: NotificationServiceType = makeNotificationService() + lazy var appSelectionCoordinator: AppSelectionCoordinatorType = makeAppSelectionCoordinator() init(inMemory: Bool = false) { self.inMemory = inMemory @@ -51,7 +57,11 @@ final class DependencyContainer { recordingRepository: recordingRepository, appSelectionViewModel: appSelectionViewModel, fileManager: recordingFileManager, - warningManager: warningManager + warningManager: warningManager, + meetingDetectionService: meetingDetectionService, + userPreferencesRepository: userPreferencesRepository, + notificationService: notificationService, + appSelectionCoordinator: appSelectionCoordinator ) } diff --git a/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift b/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift new file mode 100644 index 0000000..e9bc0ba --- /dev/null +++ b/Recap/Services/MeetingDetection/Core/MeetingDetectionService.swift @@ -0,0 +1,157 @@ +import Foundation +import ScreenCaptureKit +import Combine +import OSLog + +private struct DetectorResult { + let detector: any MeetingDetectorType + let result: MeetingDetectionResult +} + +@MainActor +final class MeetingDetectionService: MeetingDetectionServiceType { + @Published private(set) var isMeetingActive = false + @Published private(set) var activeMeetingInfo: ActiveMeetingInfo? + @Published private(set) var detectedMeetingApp: AudioProcess? + @Published private(set) var hasPermission = false + @Published private(set) var isMonitoring = false + + var meetingStatePublisher: AnyPublisher { + Publishers.CombineLatest3($isMeetingActive, $activeMeetingInfo, $detectedMeetingApp) + .map { isMeeting, meetingInfo, detectedApp in + if isMeeting, let info = meetingInfo { + return .active(info: info, detectedApp: detectedApp) + } else { + return .inactive + } + } + .removeDuplicates() + .eraseToAnyPublisher() + } + + private var monitoringTask: Task? + private var detectors: [any MeetingDetectorType] = [] + private let checkInterval: TimeInterval = 1.0 + private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "MeetingDetectionService") + private let audioProcessController: any AudioProcessControllerType + + init(audioProcessController: any AudioProcessControllerType) { + self.audioProcessController = audioProcessController + setupDetectors() + } + + private func setupDetectors() { + detectors = [ + TeamsMeetingDetector(), + ZoomMeetingDetector(), + GoogleMeetDetector() + ] + } + + func startMonitoring() { + guard !isMonitoring else { return } + + isMonitoring = true + monitoringTask?.cancel() + monitoringTask = Task { + while !Task.isCancelled { + if Task.isCancelled { break } + await checkForMeetings() + try? await Task.sleep(nanoseconds: UInt64(checkInterval * 1_000_000_000)) + } + } + } + + func stopMonitoring() { + monitoringTask?.cancel() + isMonitoring = false + monitoringTask = nil + isMeetingActive = false + activeMeetingInfo = nil + } + + private func checkForMeetings() async { + do { + let content = try await SCShareableContent.current + hasPermission = true + + var highestConfidenceResult: DetectorResult? + + for detector in detectors { + let relevantWindows = content.windows.filter { window in + guard let app = window.owningApplication else { return false } + let bundleID = app.bundleIdentifier + return detector.supportedBundleIdentifiers.contains(bundleID) + } + + if !relevantWindows.isEmpty { + let result = await detector.checkForMeeting(in: relevantWindows) + + if result.isActive { + if highestConfidenceResult == nil { + highestConfidenceResult = DetectorResult(detector: detector, result: result) + } else if let currentResult = highestConfidenceResult { + if result.confidence.rawValue > currentResult.result.confidence.rawValue { + highestConfidenceResult = DetectorResult(detector: detector, result: result) + } + } + } + } + } + + if let detectorResult = highestConfidenceResult { + let meetingInfo = ActiveMeetingInfo( + appName: detectorResult.detector.meetingAppName, + title: detectorResult.result.title ?? "Meeting in progress", + confidence: detectorResult.result.confidence + ) + let matchedApp = findMatchingAudioProcess(bundleIdentifiers: detectorResult.detector.supportedBundleIdentifiers) + + activeMeetingInfo = meetingInfo + detectedMeetingApp = matchedApp + isMeetingActive = true + } else { + activeMeetingInfo = nil + detectedMeetingApp = nil + isMeetingActive = false + } + + } catch { + logger.error("Failed to check for meetings: \(error.localizedDescription)") + hasPermission = false + } + } + + func checkPermission() async -> Bool { + do { + _ = try await SCShareableContent.current + hasPermission = true + return true + } catch { + hasPermission = false + logger.warning("Screen recording permission denied: \(error.localizedDescription)") + return false + } + } + + private func findMatchingAudioProcess(bundleIdentifiers: Set) -> AudioProcess? { + audioProcessController.processes.first { process in + guard let processBundleID = process.bundleID else { return false } + return bundleIdentifiers.contains(processBundleID) + } + } +} + +extension MeetingDetectionResult.MeetingConfidence: Comparable { + var rawValue: Int { + switch self { + case .low: return 1 + case .medium: return 2 + case .high: return 3 + } + } + + static func < (lhs: Self, rhs: Self) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift b/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift new file mode 100644 index 0000000..cb0cdc2 --- /dev/null +++ b/Recap/Services/MeetingDetection/Core/MeetingDetectionServiceType.swift @@ -0,0 +1,45 @@ +import Foundation +import Combine +#if MOCKING +import Mockable +#endif + +@MainActor +#if MOCKING +@Mockable +#endif +protocol MeetingDetectionServiceType: ObservableObject { + var isMeetingActive: Bool { get } + var activeMeetingInfo: ActiveMeetingInfo? { get } + var detectedMeetingApp: AudioProcess? { get } + var hasPermission: Bool { get } + var isMonitoring: Bool { get } + + var meetingStatePublisher: AnyPublisher { get } + + func startMonitoring() + func stopMonitoring() + func checkPermission() async -> Bool +} + +struct ActiveMeetingInfo { + let appName: String + let title: String + let confidence: MeetingDetectionResult.MeetingConfidence +} + +enum MeetingState: Equatable { + case inactive + case active(info: ActiveMeetingInfo, detectedApp: AudioProcess?) + + static func == (lhs: MeetingState, rhs: MeetingState) -> Bool { + switch (lhs, rhs) { + case (.inactive, .inactive): + return true + case (.active(let lhsInfo, _), .active(let rhsInfo, _)): + return lhsInfo.title == rhsInfo.title && lhsInfo.appName == rhsInfo.appName + default: + return false + } + } +} \ No newline at end of file diff --git a/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift b/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift new file mode 100644 index 0000000..65b967e --- /dev/null +++ b/Recap/Services/MeetingDetection/Detectors/GoogleMeetDetector.swift @@ -0,0 +1,42 @@ +import Foundation +import ScreenCaptureKit + +@MainActor +final class GoogleMeetDetector: MeetingDetectorType { + @Published private(set) var isMeetingActive = false + @Published private(set) var meetingTitle: String? + + let meetingAppName = "Google Meet" + let supportedBundleIdentifiers: Set = [ + "com.google.Chrome", + "com.apple.Safari", + "org.mozilla.firefox", + "com.microsoft.edgemac" + ] + + private let patternMatcher: MeetingPatternMatcher + + init() { + self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.googleMeetPatterns) + } + + func checkForMeeting(in windows: [SCWindow]) async -> MeetingDetectionResult { + for window in windows { + guard let title = window.title, !title.isEmpty else { continue } + + if let confidence = patternMatcher.findBestMatch(in: title) { + return MeetingDetectionResult( + isActive: true, + title: title, + confidence: confidence + ) + } + } + + return MeetingDetectionResult( + isActive: false, + title: nil, + confidence: .low + ) + } +} \ No newline at end of file diff --git a/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift b/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift new file mode 100644 index 0000000..7b2f22e --- /dev/null +++ b/Recap/Services/MeetingDetection/Detectors/MeetingDetectorType.swift @@ -0,0 +1,30 @@ +import Foundation +import ScreenCaptureKit +#if MOCKING +import Mockable +#endif + +@MainActor +#if MOCKING +@Mockable +#endif +protocol MeetingDetectorType: ObservableObject { + var isMeetingActive: Bool { get } + var meetingTitle: String? { get } + var meetingAppName: String { get } + var supportedBundleIdentifiers: Set { get } + + func checkForMeeting(in windows: [SCWindow]) async -> MeetingDetectionResult +} + +struct MeetingDetectionResult { + let isActive: Bool + let title: String? + let confidence: MeetingConfidence + + enum MeetingConfidence { + case high + case medium + case low + } +} \ No newline at end of file diff --git a/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift b/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift new file mode 100644 index 0000000..bcdc225 --- /dev/null +++ b/Recap/Services/MeetingDetection/Detectors/TeamsMeetingDetector.swift @@ -0,0 +1,40 @@ +import Foundation +import ScreenCaptureKit + +@MainActor +final class TeamsMeetingDetector: MeetingDetectorType { + @Published private(set) var isMeetingActive = false + @Published private(set) var meetingTitle: String? + + let meetingAppName = "Microsoft Teams" + let supportedBundleIdentifiers: Set = [ + "com.microsoft.teams", + "com.microsoft.teams2" + ] + + private let patternMatcher: MeetingPatternMatcher + + init() { + self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.teamsPatterns) + } + + func checkForMeeting(in windows: [SCWindow]) async -> MeetingDetectionResult { + for window in windows { + guard let title = window.title, !title.isEmpty else { continue } + + if let confidence = patternMatcher.findBestMatch(in: title) { + return MeetingDetectionResult( + isActive: true, + title: title, + confidence: confidence + ) + } + } + + return MeetingDetectionResult( + isActive: false, + title: nil, + confidence: .low + ) + } +} \ No newline at end of file diff --git a/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift b/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift new file mode 100644 index 0000000..eacb3bf --- /dev/null +++ b/Recap/Services/MeetingDetection/Detectors/ZoomMeetingDetector.swift @@ -0,0 +1,37 @@ +import Foundation +import ScreenCaptureKit + +@MainActor +final class ZoomMeetingDetector: MeetingDetectorType { + @Published private(set) var isMeetingActive = false + @Published private(set) var meetingTitle: String? + + let meetingAppName = "Zoom" + let supportedBundleIdentifiers: Set = ["us.zoom.xos"] + + private let patternMatcher: MeetingPatternMatcher + + init() { + self.patternMatcher = MeetingPatternMatcher(patterns: MeetingPatternMatcher.zoomPatterns) + } + + func checkForMeeting(in windows: [SCWindow]) async -> MeetingDetectionResult { + for window in windows { + guard let title = window.title, !title.isEmpty else { continue } + + if let confidence = patternMatcher.findBestMatch(in: title) { + return MeetingDetectionResult( + isActive: true, + title: title, + confidence: confidence + ) + } + } + + return MeetingDetectionResult( + isActive: false, + title: nil, + confidence: .low + ) + } +} \ No newline at end of file diff --git a/Recap/Services/Notifications/NotificationService.swift b/Recap/Services/Notifications/NotificationService.swift new file mode 100644 index 0000000..984e640 --- /dev/null +++ b/Recap/Services/Notifications/NotificationService.swift @@ -0,0 +1,55 @@ +import Foundation +import UserNotifications +import OSLog + +@MainActor +final class NotificationService: NotificationServiceType { + private let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: "NotificationService") + private let notificationCenter = UNUserNotificationCenter.current() + + func requestPermission() async -> Bool { + do { + let granted = try await notificationCenter.requestAuthorization(options: [.alert, .sound, .badge]) + return granted + } catch { + logger.error("Failed to request notification permission: \(error)") + return false + } + } + + func sendMeetingStartedNotification(appName: String, title: String) async { + let content = UNMutableNotificationContent() + content.title = "\(appName): Meeting Detected" + content.body = "Want to start recording it?" + content.sound = .default + content.categoryIdentifier = "MEETING_ACTIONS" + content.userInfo = ["action": "open_app"] + + await sendNotification(identifier: "meeting-started", content: content) + } + + func sendMeetingEndedNotification() async { + let content = UNMutableNotificationContent() + content.title = "Meeting Ended" + content.body = "The meeting has ended" + content.sound = .default + + await sendNotification(identifier: "meeting-ended", content: content) + } +} + +private extension NotificationService { + func sendNotification(identifier: String, content: UNMutableNotificationContent) async { + let request = UNNotificationRequest( + identifier: identifier, + content: content, + trigger: nil + ) + + do { + try await notificationCenter.add(request) + } catch { + logger.error("Failed to send notification \(identifier): \(error)") + } + } +} diff --git a/Recap/Services/Notifications/NotificationServiceType.swift b/Recap/Services/Notifications/NotificationServiceType.swift new file mode 100644 index 0000000..f74e7a2 --- /dev/null +++ b/Recap/Services/Notifications/NotificationServiceType.swift @@ -0,0 +1,8 @@ +import Foundation + +@MainActor +protocol NotificationServiceType { + func requestPermission() async -> Bool + func sendMeetingStartedNotification(appName: String, title: String) async + func sendMeetingEndedNotification() async +} \ No newline at end of file diff --git a/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinator.swift b/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinator.swift new file mode 100644 index 0000000..b0ee229 --- /dev/null +++ b/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinator.swift @@ -0,0 +1,27 @@ +import Foundation + +@MainActor +final class AppSelectionCoordinator: AppSelectionCoordinatorType { + private let appSelectionViewModel: AppSelectionViewModel + weak var delegate: AppSelectionCoordinatorDelegate? + + init(appSelectionViewModel: AppSelectionViewModel) { + self.appSelectionViewModel = appSelectionViewModel + self.appSelectionViewModel.delegate = self + } + + func autoSelectApp(_ app: AudioProcess) { + let selectableApp = SelectableApp(from: app) + appSelectionViewModel.selectApp(selectableApp) + } +} + +extension AppSelectionCoordinator: AppSelectionDelegate { + func didSelectApp(_ app: AudioProcess) { + delegate?.didSelectApp(app) + } + + func didClearAppSelection() { + delegate?.didClearAppSelection() + } +} \ No newline at end of file diff --git a/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinatorType.swift b/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinatorType.swift new file mode 100644 index 0000000..20f7a80 --- /dev/null +++ b/Recap/UseCases/AppSelection/Coordinator/AppSelectionCoordinatorType.swift @@ -0,0 +1,13 @@ +import Foundation + +@MainActor +protocol AppSelectionCoordinatorType { + var delegate: AppSelectionCoordinatorDelegate? { get set } + func autoSelectApp(_ app: AudioProcess) +} + +@MainActor +protocol AppSelectionCoordinatorDelegate: AnyObject { + func didSelectApp(_ app: AudioProcess) + func didClearAppSelection() +} \ No newline at end of file diff --git a/Recap/Views/AppSelection/View/AppSelectionDropdown.swift b/Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift similarity index 100% rename from Recap/Views/AppSelection/View/AppSelectionDropdown.swift rename to Recap/UseCases/AppSelection/View/AppSelectionDropdown.swift diff --git a/Recap/Views/AppSelection/ViewModel/AppSelectionViewModel.swift b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift similarity index 81% rename from Recap/Views/AppSelection/ViewModel/AppSelectionViewModel.swift rename to Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift index 9da814f..d3a4872 100644 --- a/Recap/Views/AppSelection/ViewModel/AppSelectionViewModel.swift +++ b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModel.swift @@ -10,6 +10,8 @@ final class AppSelectionViewModel: AppSelectionViewModelType { private(set) var audioProcessController: any AudioProcessControllerType weak var delegate: AppSelectionDelegate? + weak var autoSelectionDelegate: AppAutoSelectionDelegate? + private var selectedApp: SelectableApp? init(audioProcessController: any AudioProcessControllerType) { self.audioProcessController = audioProcessController @@ -20,11 +22,14 @@ final class AppSelectionViewModel: AppSelectionViewModelType { func toggleDropdown() { switch state { - case .noSelection, .selected: + case .noSelection: + state = .showingDropdown + case .selected(let app): + selectedApp = app state = .showingDropdown case .showingDropdown: - if let selectedApp = state.selectedApp { - state = .selected(selectedApp) + if let app = selectedApp { + state = .selected(app) } else { state = .noSelection } @@ -32,15 +37,23 @@ final class AppSelectionViewModel: AppSelectionViewModelType { } func selectApp(_ app: SelectableApp) { + selectedApp = app state = .selected(app) delegate?.didSelectApp(app.audioProcess) } func clearSelection() { + selectedApp = nil state = .noSelection delegate?.didClearAppSelection() } + func closeDropdown() { + if case .showingDropdown = state { + state = .noSelection + } + } + func toggleAudioFilter() { isAudioFilterEnabled.toggle() updateAvailableApps() diff --git a/Recap/Views/AppSelection/ViewModel/AppSelectionViewModelType.swift b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModelType.swift similarity index 87% rename from Recap/Views/AppSelection/ViewModel/AppSelectionViewModelType.swift rename to Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModelType.swift index 30e3675..0941427 100644 --- a/Recap/Views/AppSelection/ViewModel/AppSelectionViewModelType.swift +++ b/Recap/UseCases/AppSelection/ViewModel/AppSelectionViewModelType.swift @@ -6,6 +6,11 @@ protocol AppSelectionDelegate: AnyObject { func didClearAppSelection() } +@MainActor +protocol AppAutoSelectionDelegate: AnyObject { + func autoSelectApp(_ app: AudioProcess) +} + @MainActor protocol AppSelectionViewModelType: ObservableObject { var state: AppSelectionState { get } diff --git a/Recap/Views/Home/Components/CardBackground.swift b/Recap/UseCases/Home/Components/CardBackground.swift similarity index 100% rename from Recap/Views/Home/Components/CardBackground.swift rename to Recap/UseCases/Home/Components/CardBackground.swift diff --git a/Recap/Views/Home/Components/CustomReflectionCard.swift b/Recap/UseCases/Home/Components/CustomReflectionCard.swift similarity index 100% rename from Recap/Views/Home/Components/CustomReflectionCard.swift rename to Recap/UseCases/Home/Components/CustomReflectionCard.swift diff --git a/Recap/Views/Home/Components/HeatmapCard.swift b/Recap/UseCases/Home/Components/HeatmapCard.swift similarity index 100% rename from Recap/Views/Home/Components/HeatmapCard.swift rename to Recap/UseCases/Home/Components/HeatmapCard.swift diff --git a/Recap/Views/Home/Components/InformationCard.swift b/Recap/UseCases/Home/Components/InformationCard.swift similarity index 100% rename from Recap/Views/Home/Components/InformationCard.swift rename to Recap/UseCases/Home/Components/InformationCard.swift diff --git a/Recap/Views/Home/Components/TranscriptionCard.swift b/Recap/UseCases/Home/Components/TranscriptionCard.swift similarity index 100% rename from Recap/Views/Home/Components/TranscriptionCard.swift rename to Recap/UseCases/Home/Components/TranscriptionCard.swift diff --git a/Recap/Views/Home/View/RecapView.swift b/Recap/UseCases/Home/View/RecapView.swift similarity index 94% rename from Recap/Views/Home/View/RecapView.swift rename to Recap/UseCases/Home/View/RecapView.swift index 52f0258..a07fd8f 100644 --- a/Recap/Views/Home/View/RecapView.swift +++ b/Recap/UseCases/Home/View/RecapView.swift @@ -108,6 +108,14 @@ struct RecapHomeView: View { } } } + .toast(isPresenting: $viewModel.showErrorToast) { + AlertToast( + displayMode: .banner(.slide), + type: .error(.red), + title: "Recording Error", + subTitle: viewModel.errorMessage + ) + } } } diff --git a/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift new file mode 100644 index 0000000..0eb1be2 --- /dev/null +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+MeetingDetection.swift @@ -0,0 +1,102 @@ +import Foundation +import Combine +import SwiftUI + +// MARK: - Meeting Detection Setup +extension RecapViewModel { + func setupMeetingDetection() { + Task { + guard await shouldEnableMeetingDetection() else { return } + + setupMeetingStateObserver() + await startMonitoringIfPermissionGranted() + } + } +} + +// MARK: - Private Setup Helpers +private extension RecapViewModel { + func shouldEnableMeetingDetection() async -> Bool { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + return preferences.autoDetectMeetings + } catch { + logger.error("Failed to load meeting detection preferences: \(error)") + return false + } + } + + func setupMeetingStateObserver() { + meetingDetectionService.meetingStatePublisher + .sink { [weak self] meetingState in + guard let self = self else { return } + self.handleMeetingStateChange(meetingState) + } + .store(in: &cancellables) + } + + func startMonitoringIfPermissionGranted() async { + if await meetingDetectionService.checkPermission() { + meetingDetectionService.startMonitoring() + } else { + logger.warning("Meeting detection permission denied") + } + } +} + +// MARK: - Meeting State Handling +private extension RecapViewModel { + func handleMeetingStateChange(_ meetingState: MeetingState) { + switch meetingState { + case .active(let info, let detectedApp): + handleMeetingDetected(info: info, detectedApp: detectedApp) + case .inactive: + handleMeetingEnded() + } + } + + func handleMeetingDetected(info: ActiveMeetingInfo, detectedApp: AudioProcess?) { + autoSelectAppIfAvailable(detectedApp) + + let currentMeetingKey = "\(info.appName)-\(info.title)" + if lastNotifiedMeetingKey != currentMeetingKey { + lastNotifiedMeetingKey = currentMeetingKey + sendMeetingStartedNotification(appName: info.appName, title: info.title) + } + } + + func handleMeetingEnded() { + lastNotifiedMeetingKey = nil + sendMeetingEndedNotification() + } +} + +// MARK: - App Auto-Selection +private extension RecapViewModel { + func autoSelectAppIfAvailable(_ detectedApp: AudioProcess?) { + guard let detectedApp = detectedApp else { + return + } + + appSelectionCoordinator.autoSelectApp(detectedApp) + } +} + +// MARK: - Notification Helpers +private extension RecapViewModel { + func sendMeetingStartedNotification(appName: String, title: String) { + Task { + await notificationService.requestPermission() + await notificationService.sendMeetingStartedNotification(appName: appName, title: title) + } + } + + func sendMeetingEndedNotification() { + // TODO: Analyze audio levels, and if silence is detected, send a notification here. + } +} + +// MARK: - Supporting Types +private enum MeetingDetectionConstants { + static let autoSelectionAnimationDuration: Double = 0.3 +} diff --git a/Recap/Views/Home/ViewModel/RecapViewModel+Processing.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+Processing.swift similarity index 100% rename from Recap/Views/Home/ViewModel/RecapViewModel+Processing.swift rename to Recap/UseCases/Home/ViewModel/RecapViewModel+Processing.swift diff --git a/Recap/Views/Home/ViewModel/RecapViewModel+RecordingFailure.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+RecordingFailure.swift similarity index 100% rename from Recap/Views/Home/ViewModel/RecapViewModel+RecordingFailure.swift rename to Recap/UseCases/Home/ViewModel/RecapViewModel+RecordingFailure.swift diff --git a/Recap/Views/Home/ViewModel/RecapViewModel+StartRecording.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+StartRecording.swift similarity index 95% rename from Recap/Views/Home/ViewModel/RecapViewModel+StartRecording.swift rename to Recap/UseCases/Home/ViewModel/RecapViewModel+StartRecording.swift index f17a1ab..a7c912e 100644 --- a/Recap/Views/Home/ViewModel/RecapViewModel+StartRecording.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel+StartRecording.swift @@ -3,6 +3,7 @@ import OSLog extension RecapViewModel { func startRecording() async { + syncRecordingStateWithCoordinator() guard !isRecording else { return } guard let selectedApp = selectedApp else { return } @@ -71,5 +72,7 @@ extension RecapViewModel { errorMessage = error.localizedDescription logger.error("Failed to start recording: \(error)") currentRecordingID = nil + updateRecordingUIState(started: false) + showErrorToast = true } } \ No newline at end of file diff --git a/Recap/Views/Home/ViewModel/RecapViewModel+StopRecording.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+StopRecording.swift similarity index 100% rename from Recap/Views/Home/ViewModel/RecapViewModel+StopRecording.swift rename to Recap/UseCases/Home/ViewModel/RecapViewModel+StopRecording.swift diff --git a/Recap/Views/Home/ViewModel/RecapViewModel+Timers.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel+Timers.swift similarity index 100% rename from Recap/Views/Home/ViewModel/RecapViewModel+Timers.swift rename to Recap/UseCases/Home/ViewModel/RecapViewModel+Timers.swift diff --git a/Recap/Views/Home/ViewModel/RecapViewModel.swift b/Recap/UseCases/Home/ViewModel/RecapViewModel.swift similarity index 72% rename from Recap/Views/Home/ViewModel/RecapViewModel.swift rename to Recap/UseCases/Home/ViewModel/RecapViewModel.swift index 1e67914..85b8813 100644 --- a/Recap/Views/Home/ViewModel/RecapViewModel.swift +++ b/Recap/UseCases/Home/ViewModel/RecapViewModel.swift @@ -17,32 +17,46 @@ final class RecapViewModel: ObservableObject { @Published var microphoneLevel: Float = 0.0 @Published var systemAudioLevel: Float = 0.0 @Published var errorMessage: String? - @Published private(set) var selectedApp: AudioProcess? @Published var isMicrophoneEnabled = false @Published var currentRecordings: [RecordingInfo] = [] + @Published var showErrorToast = false + @Published private(set) var processingState: ProcessingState = .idle @Published private(set) var activeWarnings: [WarningItem] = [] - + @Published private(set) var selectedApp: AudioProcess? + let recordingCoordinator: RecordingCoordinator let processingCoordinator: ProcessingCoordinator let recordingRepository: RecordingRepositoryType let appSelectionViewModel: AppSelectionViewModel let fileManager: RecordingFileManaging - let warningManager: WarningManagerType + let warningManager: any WarningManagerType + let meetingDetectionService: any MeetingDetectionServiceType + let userPreferencesRepository: UserPreferencesRepositoryType + let notificationService: any NotificationServiceType + var appSelectionCoordinator: any AppSelectionCoordinatorType + var timer: Timer? var levelTimer: Timer? - let logger = Logger(subsystem: "com.recap.audio", category: String(describing: RecapViewModel.self)) + let logger = Logger(subsystem: AppConstants.Logging.subsystem, category: String(describing: RecapViewModel.self)) + weak var delegate: RecapViewModelDelegate? + var currentRecordingID: String? - private var cancellables = Set() + var lastNotifiedMeetingKey: String? + var cancellables = Set() init( recordingCoordinator: RecordingCoordinator, processingCoordinator: ProcessingCoordinator, recordingRepository: RecordingRepositoryType, appSelectionViewModel: AppSelectionViewModel, fileManager: RecordingFileManaging, - warningManager: WarningManagerType + warningManager: any WarningManagerType, + meetingDetectionService: any MeetingDetectionServiceType, + userPreferencesRepository: UserPreferencesRepositoryType, + notificationService: any NotificationServiceType, + appSelectionCoordinator: any AppSelectionCoordinatorType ) { self.recordingCoordinator = recordingCoordinator self.processingCoordinator = processingCoordinator @@ -50,11 +64,15 @@ final class RecapViewModel: ObservableObject { self.appSelectionViewModel = appSelectionViewModel self.fileManager = fileManager self.warningManager = warningManager + self.meetingDetectionService = meetingDetectionService + self.userPreferencesRepository = userPreferencesRepository + self.notificationService = notificationService + self.appSelectionCoordinator = appSelectionCoordinator setupBindings() setupWarningObserver() - appSelectionViewModel.delegate = self - processingCoordinator.delegate = self + setupMeetingDetection() + setupDelegates() Task { await loadRecordings() @@ -73,17 +91,21 @@ final class RecapViewModel: ObservableObject { appSelectionViewModel.refreshAvailableApps() } + private func setupDelegates() { + appSelectionCoordinator.delegate = self + processingCoordinator.delegate = self + } + var currentRecordingLevel: Float { recordingCoordinator.currentAudioLevel } - var hasAvailableApps: Bool { !appSelectionViewModel.availableApps.isEmpty } var canStartRecording: Bool { - selectedApp != nil + selectedApp != nil && !recordingCoordinator.isRecording } func toggleMicrophone() { @@ -135,6 +157,16 @@ final class RecapViewModel: ObservableObject { } } + func syncRecordingStateWithCoordinator() { + let coordinatorIsRecording = recordingCoordinator.isRecording + if isRecording != coordinatorIsRecording { + updateRecordingUIState(started: coordinatorIsRecording) + if !coordinatorIsRecording { + currentRecordingID = nil + } + } + } + deinit { Task.detached { [weak self] in await self?.stopTimers() @@ -142,7 +174,7 @@ final class RecapViewModel: ObservableObject { } } -extension RecapViewModel: AppSelectionDelegate { +extension RecapViewModel: AppSelectionCoordinatorDelegate { func didSelectApp(_ app: AudioProcess) { selectApp(app) } diff --git a/Recap/Views/PreviousRecaps/View/Components/RecordingCard.swift b/Recap/UseCases/PreviousRecaps/View/Components/RecordingCard.swift similarity index 100% rename from Recap/Views/PreviousRecaps/View/Components/RecordingCard.swift rename to Recap/UseCases/PreviousRecaps/View/Components/RecordingCard.swift diff --git a/Recap/Views/PreviousRecaps/View/Components/RecordingRow.swift b/Recap/UseCases/PreviousRecaps/View/Components/RecordingRow.swift similarity index 100% rename from Recap/Views/PreviousRecaps/View/Components/RecordingRow.swift rename to Recap/UseCases/PreviousRecaps/View/Components/RecordingRow.swift diff --git a/Recap/Views/PreviousRecaps/View/PreviousRecapsDropdown.swift b/Recap/UseCases/PreviousRecaps/View/PreviousRecapsDropdown.swift similarity index 100% rename from Recap/Views/PreviousRecaps/View/PreviousRecapsDropdown.swift rename to Recap/UseCases/PreviousRecaps/View/PreviousRecapsDropdown.swift diff --git a/Recap/Views/PreviousRecaps/ViewModel/PreviousRecapsViewModel.swift b/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModel.swift similarity index 100% rename from Recap/Views/PreviousRecaps/ViewModel/PreviousRecapsViewModel.swift rename to Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModel.swift diff --git a/Recap/Views/PreviousRecaps/ViewModel/PreviousRecapsViewModelType.swift b/Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModelType.swift similarity index 100% rename from Recap/Views/PreviousRecaps/ViewModel/PreviousRecapsViewModelType.swift rename to Recap/UseCases/PreviousRecaps/ViewModel/PreviousRecapsViewModelType.swift diff --git a/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionSettingsCard.swift b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionSettingsCard.swift new file mode 100644 index 0000000..a2616cc --- /dev/null +++ b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionSettingsCard.swift @@ -0,0 +1,118 @@ +import SwiftUI +import ScreenCaptureKit + +struct MeetingDetectionSettingsCard: View { + @ObservedObject private var generalSettingsViewModel: GeneralViewModel + @ObservedObject private var viewModel: MeetingViewModel + + init(generalSettingsViewModel: GeneralViewModel, viewModel: MeetingViewModel) { + self.generalSettingsViewModel = generalSettingsViewModel + self.viewModel = viewModel + } + + var body: some View { + GeometryReader { geometry in + SettingsCard(title: "Meeting Detection") { + if viewModel.autoDetectMeetings && !viewModel.hasScreenRecordingPermission { + WarningCard( + warning: WarningItem( + id: "screen-recording", + title: "Permission Required", + message: "Screen Recording permission needed to detect meeting windows", + icon: "exclamationmark.shield", + severity: .warning + ), + containerWidth: geometry.size.width + ) + } + + VStack(spacing: 16) { + settingsRow( + label: "Auto-detect meetings", + description: "Get notified in console when Teams, Zoom, or Meet meetings begin" + ) { + Toggle("", isOn: Binding( + get: { viewModel.autoDetectMeetings }, + set: { newValue in + Task { + await viewModel.handleAutoDetectToggle(newValue) + } + } + )) + .toggleStyle(CustomToggleStyle()) + .labelsHidden() + } + + if viewModel.autoDetectMeetings { + VStack(spacing: 12) { + if !viewModel.hasScreenRecordingPermission { + VStack(alignment: .leading, spacing: 8) { + PillButton( + text: "Open System Settings", + icon: "gear" + ) { + viewModel.openScreenRecordingPreferences() + } + + Text("This permission allows Recap to read window titles only. No screen content is captured or recorded.") + .font(.system(size: 10)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + } else { + HStack { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + .font(.system(size: 12)) + Text("Screen Recording permission granted") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } + } + } + .padding(.top, 8) + } + } + } + .onAppear { + Task { + await viewModel.checkPermissionStatus() + } + } + .onChange(of: viewModel.autoDetectMeetings) { enabled in + if enabled { + Task { + await viewModel.checkPermissionStatus() + } + } + } + } + } + + private func settingsRow( + label: String, + description: String? = nil, + @ViewBuilder control: () -> Content + ) -> some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + if let description = description { + Text(description) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + control() + } + } + +} diff --git a/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift new file mode 100644 index 0000000..0813ee2 --- /dev/null +++ b/Recap/UseCases/Settings/Components/MeetingDetection/MeetingDetectionView.swift @@ -0,0 +1,108 @@ +import SwiftUI + +struct MeetingDetectionView: View { + @ObservedObject private var viewModel: ViewModel + + init(viewModel: ViewModel) { + self.viewModel = viewModel + } + + var body: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 16) { + if viewModel.autoDetectMeetings && !viewModel.hasScreenRecordingPermission { + ActionableWarningCard( + warning: WarningItem( + id: "screen-recording", + title: "Permission Required", + message: "Screen Recording permission needed to detect meeting windows", + icon: "exclamationmark.shield", + severity: .warning + ), + containerWidth: geometry.size.width, + buttonText: "Open System Settings", + buttonAction: { + viewModel.openScreenRecordingPreferences() + }, + footerText: "This permission allows Recap to read window titles only. No screen content is captured or recorded." + ) + } + + SettingsCard(title: "Meeting Detection") { + VStack(spacing: 16) { + settingsRow( + label: "Auto-detect meetings", + description: "Get notified in console when Teams, Zoom, or Meet meetings begin" + ) { + Toggle("", isOn: Binding( + get: { viewModel.autoDetectMeetings }, + set: { newValue in + Task { + await viewModel.handleAutoDetectToggle(newValue) + } + } + )) + .toggleStyle(CustomToggleStyle()) + .labelsHidden() + } + + if viewModel.autoDetectMeetings { + VStack(spacing: 12) { + if !viewModel.hasScreenRecordingPermission { + Text("Please enable Screen Recording permission above to continue.") + .font(.system(size: 10)) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + } + .padding(.top, 8) + } + } + } + + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + } + } + .onAppear { + Task { + await viewModel.checkPermissionStatus() + } + } + .onChange(of: viewModel.autoDetectMeetings) { enabled in + if enabled { + Task { + await viewModel.checkPermissionStatus() + } + } + } + } + + private func settingsRow( + label: String, + description: String? = nil, + @ViewBuilder control: () -> Content + ) -> some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(UIConstants.Colors.textPrimary) + + if let description = description { + Text(description) + .font(.system(size: 10)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + Spacer() + + control() + } + } + +} diff --git a/Recap/Views/Settings/Components/CustomDropdown.swift b/Recap/UseCases/Settings/Components/Reusable/CustomDropdown.swift similarity index 100% rename from Recap/Views/Settings/Components/CustomDropdown.swift rename to Recap/UseCases/Settings/Components/Reusable/CustomDropdown.swift diff --git a/Recap/Views/Settings/Components/CustomSegmentedControl.swift b/Recap/UseCases/Settings/Components/Reusable/CustomSegmentedControl.swift similarity index 100% rename from Recap/Views/Settings/Components/CustomSegmentedControl.swift rename to Recap/UseCases/Settings/Components/Reusable/CustomSegmentedControl.swift diff --git a/Recap/Views/Settings/Components/CustomToggle.swift b/Recap/UseCases/Settings/Components/Reusable/CustomToggle.swift similarity index 100% rename from Recap/Views/Settings/Components/CustomToggle.swift rename to Recap/UseCases/Settings/Components/Reusable/CustomToggle.swift diff --git a/Recap/Views/Settings/Components/SettingsCard.swift b/Recap/UseCases/Settings/Components/SettingsCard.swift similarity index 100% rename from Recap/Views/Settings/Components/SettingsCard.swift rename to Recap/UseCases/Settings/Components/SettingsCard.swift diff --git a/Recap/Views/Settings/Components/GeneralSettingsView.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift similarity index 80% rename from Recap/Views/Settings/Components/GeneralSettingsView.swift rename to Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift index 18393e8..6e98d48 100644 --- a/Recap/Views/Settings/Components/GeneralSettingsView.swift +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift @@ -10,7 +10,7 @@ struct GeneralSettingsView: View { var body: some View { GeometryReader { geometry in - ScrollView(showsIndicators: false) { + ScrollView() { VStack(alignment: .leading, spacing: 16) { ForEach(viewModel.activeWarnings, id: \.id) { warning in WarningCard(warning: warning, containerWidth: geometry.size.width) @@ -86,35 +86,6 @@ struct GeneralSettingsView: View { } } - SettingsCard(title: "Recording Settings (Coming Soon)") { - VStack(spacing: 16) { - settingsRow(label: "Auto Detect Meetings") { - Toggle("", isOn: Binding( - get: { viewModel.isAutoDetectMeetings }, - set: { newValue in - Task { - await viewModel.toggleAutoDetectMeetings(newValue) - } - } - )) - .toggleStyle(CustomToggleStyle()) - .labelsHidden() - } - - settingsRow(label: "Auto Stop Recording") { - Toggle("", isOn: Binding( - get: { viewModel.isAutoStopRecording }, - set: { newValue in - Task { - await viewModel.toggleAutoStopRecording(newValue) - } - } - )) - .toggleStyle(CustomToggleStyle()) - .labelsHidden() - } - } - } } .padding(.horizontal, 20) .padding(.vertical, 20) @@ -162,7 +133,7 @@ private final class PreviewGeneralSettingsViewModel: ObservableObject, GeneralSe ] @Published var selectedModel: LLMModelInfo? @Published var selectedProvider: LLMProvider = .ollama - @Published var isAutoDetectMeetings: Bool = true + @Published var autoDetectMeetings: Bool = true @Published var isAutoStopRecording: Bool = false @Published var isLoading = false @Published var errorMessage: String? @@ -194,7 +165,7 @@ private final class PreviewGeneralSettingsViewModel: ObservableObject, GeneralSe selectedProvider = provider } func toggleAutoDetectMeetings(_ enabled: Bool) async { - isAutoDetectMeetings = enabled + autoDetectMeetings = enabled } func toggleAutoStopRecording(_ enabled: Bool) async { isAutoStopRecording = enabled diff --git a/Recap/Views/Settings/Components/WhisperModelsView.swift b/Recap/UseCases/Settings/Components/TabViews/WhisperModelsView.swift similarity index 100% rename from Recap/Views/Settings/Components/WhisperModelsView.swift rename to Recap/UseCases/Settings/Components/TabViews/WhisperModelsView.swift diff --git a/Recap/Views/Settings/Models/ModelInfo.swift b/Recap/UseCases/Settings/Models/ModelInfo.swift similarity index 100% rename from Recap/Views/Settings/Models/ModelInfo.swift rename to Recap/UseCases/Settings/Models/ModelInfo.swift diff --git a/Recap/Views/Settings/Models/ProviderStatus.swift b/Recap/UseCases/Settings/Models/ProviderStatus.swift similarity index 100% rename from Recap/Views/Settings/Models/ProviderStatus.swift rename to Recap/UseCases/Settings/Models/ProviderStatus.swift diff --git a/Recap/Views/Settings/SettingsView.swift b/Recap/UseCases/Settings/SettingsView.swift similarity index 81% rename from Recap/Views/Settings/SettingsView.swift rename to Recap/UseCases/Settings/SettingsView.swift index 635be4e..4a824ec 100644 --- a/Recap/Views/Settings/SettingsView.swift +++ b/Recap/UseCases/Settings/SettingsView.swift @@ -2,12 +2,15 @@ import SwiftUI enum SettingsTab: CaseIterable { case general + case meetingDetection case whisperModels var title: String { switch self { case .general: return "General" + case .meetingDetection: + return "Meeting Detection" case .whisperModels: return "Whisper Models" } @@ -18,8 +21,25 @@ struct SettingsView: View { @State private var selectedTab: SettingsTab = .general @ObservedObject var whisperModelsViewModel: WhisperModelsViewModel @ObservedObject var generalSettingsViewModel: GeneralViewModel + @StateObject private var meetingDetectionViewModel: MeetingDetectionSettingsViewModel let onClose: () -> Void + init( + whisperModelsViewModel: WhisperModelsViewModel, + generalSettingsViewModel: GeneralViewModel, + meetingDetectionService: MeetingDetectionServiceType, + userPreferencesRepository: UserPreferencesRepositoryType, + onClose: @escaping () -> Void + ) { + self.whisperModelsViewModel = whisperModelsViewModel + self.generalSettingsViewModel = generalSettingsViewModel + self._meetingDetectionViewModel = StateObject(wrappedValue: MeetingDetectionSettingsViewModel( + detectionService: meetingDetectionService, + userPreferencesRepository: userPreferencesRepository + )) + self.onClose = onClose + } + var body: some View { GeometryReader { geometry in ZStack { @@ -86,7 +106,11 @@ struct SettingsView: View { Group { switch selectedTab { case .general: - GeneralSettingsView(viewModel: generalSettingsViewModel) + GeneralSettingsView( + viewModel: generalSettingsViewModel + ) + case .meetingDetection: + MeetingDetectionView(viewModel: meetingDetectionViewModel) case .whisperModels: WhisperModelsView(viewModel: whisperModelsViewModel) } @@ -119,6 +143,8 @@ struct SettingsView: View { SettingsView( whisperModelsViewModel: whisperModelsViewModel, generalSettingsViewModel: generalSettingsViewModel, + meetingDetectionService: MeetingDetectionService(audioProcessController: AudioProcessController()), + userPreferencesRepository: UserPreferencesRepository(coreDataManager: coreDataManager), onClose: {} ) .frame(width: 550, height: 500) @@ -133,7 +159,7 @@ private final class PreviewGeneralSettingsViewModel: ObservableObject, GeneralSe ] @Published var selectedModel: LLMModelInfo? @Published var selectedProvider: LLMProvider = .ollama - @Published var isAutoDetectMeetings: Bool = true + @Published var autoDetectMeetings: Bool = true @Published var isAutoStopRecording: Bool = false @Published var isLoading = false @Published var errorMessage: String? @@ -156,7 +182,7 @@ private final class PreviewGeneralSettingsViewModel: ObservableObject, GeneralSe selectedProvider = provider } func toggleAutoDetectMeetings(_ enabled: Bool) async { - isAutoDetectMeetings = enabled + autoDetectMeetings = enabled } func toggleAutoStopRecording(_ enabled: Bool) async { isAutoStopRecording = enabled diff --git a/Recap/Views/Settings/ViewModels/General/GeneralSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift similarity index 95% rename from Recap/Views/Settings/ViewModels/General/GeneralSettingsViewModel.swift rename to Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift index fdb56e2..37f05de 100644 --- a/Recap/Views/Settings/ViewModels/General/GeneralSettingsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift @@ -6,7 +6,7 @@ final class GeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModel @Published private(set) var availableModels: [LLMModelInfo] = [] @Published private(set) var selectedModel: LLMModelInfo? @Published private(set) var selectedProvider: LLMProvider = .default - @Published private(set) var isAutoDetectMeetings: Bool = false + @Published private(set) var autoDetectMeetings: Bool = false @Published private(set) var isAutoStopRecording: Bool = false @Published private(set) var isLoading = false @Published private(set) var errorMessage: String? @@ -56,11 +56,11 @@ final class GeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModel do { let preferences = try await llmService.getUserPreferences() selectedProvider = preferences.selectedProvider - isAutoDetectMeetings = preferences.autoDetectMeetings + autoDetectMeetings = preferences.autoDetectMeetings isAutoStopRecording = preferences.autoStopRecording } catch { selectedProvider = .default - isAutoDetectMeetings = false + autoDetectMeetings = false isAutoStopRecording = false } await loadModels() @@ -146,13 +146,13 @@ final class GeneralSettingsViewModel: ObservableObject, GeneralSettingsViewModel func toggleAutoDetectMeetings(_ enabled: Bool) async { errorMessage = nil - isAutoDetectMeetings = enabled + autoDetectMeetings = enabled do { try await userPreferencesRepository.updateAutoDetectMeetings(enabled) } catch { errorMessage = error.localizedDescription - isAutoDetectMeetings = !enabled + autoDetectMeetings = !enabled } } diff --git a/Recap/Views/Settings/ViewModels/General/GeneralSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift similarity index 94% rename from Recap/Views/Settings/ViewModels/General/GeneralSettingsViewModelType.swift rename to Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift index d9d710e..e84ce80 100644 --- a/Recap/Views/Settings/ViewModels/General/GeneralSettingsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift @@ -6,7 +6,7 @@ protocol GeneralSettingsViewModelType: ObservableObject { var availableModels: [LLMModelInfo] { get } var selectedModel: LLMModelInfo? { get } var selectedProvider: LLMProvider { get } - var isAutoDetectMeetings: Bool { get } + var autoDetectMeetings: Bool { get } var isAutoStopRecording: Bool { get } var isLoading: Bool { get } var errorMessage: String? { get } diff --git a/Recap/Views/Settings/ViewModels/LLM/LLMModelsViewModel.swift b/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModel.swift similarity index 100% rename from Recap/Views/Settings/ViewModels/LLM/LLMModelsViewModel.swift rename to Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModel.swift diff --git a/Recap/Views/Settings/ViewModels/LLM/LLMModelsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModelType.swift similarity index 100% rename from Recap/Views/Settings/ViewModels/LLM/LLMModelsViewModelType.swift rename to Recap/UseCases/Settings/ViewModels/LLM/LLMModelsViewModelType.swift diff --git a/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift new file mode 100644 index 0000000..42f3eaf --- /dev/null +++ b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModel.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftUI + +@MainActor +final class MeetingDetectionSettingsViewModel: MeetingDetectionSettingsViewModelType { + @Published var hasScreenRecordingPermission = false + @Published var autoDetectMeetings = false + + private let detectionService: any MeetingDetectionServiceType + private let userPreferencesRepository: UserPreferencesRepositoryType + + init(detectionService: any MeetingDetectionServiceType, + userPreferencesRepository: UserPreferencesRepositoryType) { + self.detectionService = detectionService + self.userPreferencesRepository = userPreferencesRepository + + Task { + await loadCurrentSettings() + } + } + + private func loadCurrentSettings() async { + guard let preferences = try? await userPreferencesRepository.getOrCreatePreferences() else { + return + } + + withAnimation(.easeInOut(duration: 0.2)) { + autoDetectMeetings = preferences.autoDetectMeetings + } + } + + func handleAutoDetectToggle(_ enabled: Bool) async { + try? await userPreferencesRepository.updateAutoDetectMeetings(enabled) + + withAnimation(.easeInOut(duration: 0.2)) { + autoDetectMeetings = enabled + } + + if enabled { + let hasPermission = await detectionService.checkPermission() + hasScreenRecordingPermission = hasPermission + + if hasPermission { + detectionService.startMonitoring() + } else { + openScreenRecordingPreferences() + } + } else { + detectionService.stopMonitoring() + } + + } + + func checkPermissionStatus() async { + hasScreenRecordingPermission = await detectionService.checkPermission() + + if autoDetectMeetings && hasScreenRecordingPermission { + detectionService.startMonitoring() + } + } + + func openScreenRecordingPreferences() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift new file mode 100644 index 0000000..b2e6c3e --- /dev/null +++ b/Recap/UseCases/Settings/ViewModels/MeetingDetection/MeetingDetectionSettingsViewModelType.swift @@ -0,0 +1,11 @@ +import Foundation + +@MainActor +protocol MeetingDetectionSettingsViewModelType: ObservableObject { + var hasScreenRecordingPermission: Bool { get } + var autoDetectMeetings: Bool { get } + + func handleAutoDetectToggle(_ enabled: Bool) async + func checkPermissionStatus() async + func openScreenRecordingPreferences() +} \ No newline at end of file diff --git a/Recap/Views/Settings/ViewModels/Whisper/WhisperModelsViewModel.swift b/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModel.swift similarity index 100% rename from Recap/Views/Settings/ViewModels/Whisper/WhisperModelsViewModel.swift rename to Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModel.swift diff --git a/Recap/Views/Settings/ViewModels/Whisper/WhisperModelsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelType.swift similarity index 100% rename from Recap/Views/Settings/ViewModels/Whisper/WhisperModelsViewModelType.swift rename to Recap/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelType.swift diff --git a/Recap/Views/Summary/Components/ProcessingProgressBar.swift b/Recap/UseCases/Summary/Components/ProcessingProgressBar.swift similarity index 100% rename from Recap/Views/Summary/Components/ProcessingProgressBar.swift rename to Recap/UseCases/Summary/Components/ProcessingProgressBar.swift diff --git a/Recap/Views/Summary/Components/ProcessingStatesCard.swift b/Recap/UseCases/Summary/Components/ProcessingStatesCard.swift similarity index 100% rename from Recap/Views/Summary/Components/ProcessingStatesCard.swift rename to Recap/UseCases/Summary/Components/ProcessingStatesCard.swift diff --git a/Recap/Views/Summary/SummaryView.swift b/Recap/UseCases/Summary/SummaryView.swift similarity index 100% rename from Recap/Views/Summary/SummaryView.swift rename to Recap/UseCases/Summary/SummaryView.swift diff --git a/Recap/Views/Summary/ViewModel/SummaryViewModel.swift b/Recap/UseCases/Summary/ViewModel/SummaryViewModel.swift similarity index 100% rename from Recap/Views/Summary/ViewModel/SummaryViewModel.swift rename to Recap/UseCases/Summary/ViewModel/SummaryViewModel.swift diff --git a/Recap/Views/Summary/ViewModel/SummaryViewModelType.swift b/Recap/UseCases/Summary/ViewModel/SummaryViewModelType.swift similarity index 100% rename from Recap/Views/Summary/ViewModel/SummaryViewModelType.swift rename to Recap/UseCases/Summary/ViewModel/SummaryViewModelType.swift diff --git a/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift b/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift new file mode 100644 index 0000000..2cf7332 --- /dev/null +++ b/RecapTests/Services/MeetingDetection/MeetingDetectionServiceSpec.swift @@ -0,0 +1,162 @@ +import XCTest +import Combine +@testable import Recap +import Mockable + +@MainActor +final class MeetingDetectionServiceSpec: XCTestCase { + private var sut: MeetingDetectionService! + private var mockAudioProcessController: MockAudioProcessControllerType! + private var cancellables: Set! + + override func setUp() async throws { + try await super.setUp() + + mockAudioProcessController = MockAudioProcessControllerType() + cancellables = Set() + + let emptyProcesses: [AudioProcess] = [] + let emptyGroups: [AudioProcessGroup] = [] + + given(mockAudioProcessController) + .processes + .willReturn(emptyProcesses) + + given(mockAudioProcessController) + .processGroups + .willReturn(emptyGroups) + + given(mockAudioProcessController) + .meetingApps + .willReturn(emptyProcesses) + + sut = MeetingDetectionService(audioProcessController: mockAudioProcessController) + } + + override func tearDown() async throws { + sut = nil + mockAudioProcessController = nil + cancellables = nil + + try await super.tearDown() + } + + // MARK: - Initialization Tests + + func testInitialState() { + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.activeMeetingInfo) + XCTAssertNil(sut.detectedMeetingApp) + XCTAssertFalse(sut.hasPermission) + XCTAssertFalse(sut.isMonitoring) + } + + // MARK: - Monitoring Tests + + func testStartMonitoring() { + XCTAssertFalse(sut.isMonitoring) + + sut.startMonitoring() + + XCTAssertTrue(sut.isMonitoring) + } + + func testStopMonitoring() { + sut.startMonitoring() + XCTAssertTrue(sut.isMonitoring) + + sut.stopMonitoring() + + XCTAssertFalse(sut.isMonitoring) + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.activeMeetingInfo) + XCTAssertNil(sut.detectedMeetingApp) + } + + func testStartMonitoringTwiceDoesNotDuplicate() { + sut.startMonitoring() + let firstIsMonitoring = sut.isMonitoring + + sut.startMonitoring() + + XCTAssertEqual(firstIsMonitoring, sut.isMonitoring) + XCTAssertTrue(sut.isMonitoring) + } + + func testMeetingStatePublisherEmitsInactive() async throws { + let expectation = XCTestExpectation(description: "Meeting state publisher emits inactive") + + sut.meetingStatePublisher + .sink { state in + if case .inactive = state { + expectation.fulfill() + } + } + .store(in: &cancellables) + + await fulfillment(of: [expectation], timeout: 1.0) + } + + func testMeetingStatePublisherRemovesDuplicates() async throws { + var receivedStates: [MeetingState] = [] + + sut.meetingStatePublisher + .sink { state in + receivedStates.append(state) + } + .store(in: &cancellables) + + try await Task.sleep(nanoseconds: 100_000_000) + + XCTAssertEqual(receivedStates.count, 1) + XCTAssertEqual(receivedStates.first, .inactive) + } + + + func testStopMonitoringClearsAllState() { + sut.startMonitoring() + + sut.stopMonitoring() + + XCTAssertFalse(sut.isMeetingActive) + XCTAssertNil(sut.activeMeetingInfo) + XCTAssertNil(sut.detectedMeetingApp) + XCTAssertFalse(sut.isMonitoring) + } + + func testMeetingDetectionServiceRespectsControllerProcesses() { + let teamsProcess = TestData.createAudioProcess( + name: "Microsoft Teams", + bundleID: "com.microsoft.teams2" + ) + + let processes: [RecapTests.AudioProcess] = [teamsProcess] + + given(mockAudioProcessController) + .processes + .willReturn(processes) + + verify(mockAudioProcessController) + .processes + .called(0) + } +} + +// MARK: - Test Helpers + +private enum TestData { + static func createAudioProcess( + name: String, + bundleID: String? = nil + ) -> RecapTests.AudioProcess { + RecapTests.AudioProcess( + id: pid_t(Int32.random(in: 1000...9999)), + kind: .app, + name: name, + audioActive: true, + bundleID: bundleID, + bundleURL: nil, + objectID: 0 + ) + } +} diff --git a/RecapTests/Views/Settings/ViewModels/Whisper/WhisperModelsViewModelSpec.swift b/RecapTests/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelSpec.swift similarity index 100% rename from RecapTests/Views/Settings/ViewModels/Whisper/WhisperModelsViewModelSpec.swift rename to RecapTests/UseCases/Settings/ViewModels/Whisper/WhisperModelsViewModelSpec.swift diff --git a/RecapTests/Views/Summary/ViewModels/SummaryViewModelSpec.swift b/RecapTests/UseCases/Summary/ViewModels/SummaryViewModelSpec.swift similarity index 100% rename from RecapTests/Views/Summary/ViewModels/SummaryViewModelSpec.swift rename to RecapTests/UseCases/Summary/ViewModels/SummaryViewModelSpec.swift