Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Sources/DualCameraKit/DualCameraController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,11 @@ extension DualCameraControlling {

// default implementations for `DualCameraPhotoCapturing` - proxy to implementation in `photoCapturer`
public extension DualCameraControlling {
public func captureCurrentScreen(mode: DualCameraPhotoCaptureMode = .fullScreen) async throws -> UIImage {
func captureCurrentScreen(mode: DualCameraPhotoCaptureMode = .fullScreen) async throws -> UIImage {
try await photoCapturer.captureCurrentScreen(mode: mode)
}

public func captureRawPhotos() async throws -> (front: UIImage, back: UIImage) {
func captureRawPhotos() async throws -> (front: UIImage, back: UIImage) {
let frontRenderer = getRenderer(for: .front)
let backRenderer = getRenderer(for: .back)

Expand Down Expand Up @@ -133,7 +133,7 @@ public final class DualCameraController: DualCameraControlling {
let task = Task {
for await buffer in stream {
if Task.isCancelled { break }
await renderer.update(with: buffer.buffer)
renderer.update(with: buffer.buffer)
}
}
streamTasks[source] = task
Expand Down Expand Up @@ -213,7 +213,7 @@ public final class DualCameraMockController: DualCameraControlling {
let task = Task {
for await buffer in stream {
if Task.isCancelled { break }
await renderer.update(with: buffer.buffer)
renderer.update(with: buffer.buffer)
}
}
streamTasks[source] = task
Expand Down
16 changes: 15 additions & 1 deletion Sources/DualCameraKit/DualCameraEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,22 @@ import UIKit
/// A value-type container for default services,
/// allowing dependency injection with safe, testable defaults.
/// Inspired by https://www.pointfree.co/episodes/ep16-dependency-injection-made-easy
/// Trying this out for now. It still feels kinda weird and so
/// at some point it might be worth moving it to a more formal DI.
public struct DualCameraEnvironment: Sendable {
public var mediaLibraryService: MediaLibraryService = .live()

public var dualCameraController: DualCameraControlling = Self.getDefaultCameraController()

@MainActor
static func getDefaultCameraController() -> DualCameraControlling {
#if targetEnvironment(simulator)
return DualCameraMockController()
#else
return DualCameraController()
#endif
}
}

// swiftlint:disable:next identifier_name
public let CurrentDualCameraEnvironment = DualCameraEnvironment()
public var CurrentDualCameraEnvironment = DualCameraEnvironment()
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import UIKit
/// `saveToPhotoLibrary` involves permission grant to write to user's photo library.
///
/// `custom` opts-out of saving to user's photo library; useful for when you don't need to save to library or want to handle permission flow in a custom way.
public enum MediaSaveStrategy<Media: Sendable>: Sendable {
public enum DualCameraMediaSaveStrategy<Media: Sendable>: Sendable {
case saveToMediaLibrary(@Sendable (Media) async throws -> Void)
case custom(@Sendable (Media) async throws -> Void)

Expand All @@ -21,25 +21,25 @@ public enum MediaSaveStrategy<Media: Sendable>: Sendable {
}

// MARK: - Type Aliases
public typealias PhotoSaveStrategy = MediaSaveStrategy<UIImage>
public typealias VideoSaveStrategy = MediaSaveStrategy<URL>
public typealias DualCameraPhotoSaveStrategy = DualCameraMediaSaveStrategy<UIImage>
public typealias DualCameraVideoSaveStrategy = DualCameraMediaSaveStrategy<URL>

public extension PhotoSaveStrategy {
public extension DualCameraPhotoSaveStrategy {
/// Creates a strategy that saves photos to the user's media library using the provided service.
/// - Parameter service: The service responsible for handling photo library operations.
/// - Returns: A configured save strategy for photos.
static func photoLibrary(service: MediaLibraryService) -> PhotoSaveStrategy {
static func photoLibrary(service: MediaLibraryService) -> DualCameraPhotoSaveStrategy {
.saveToMediaLibrary { [service] image in
try await service.saveImage(image)
}
}
}

public extension VideoSaveStrategy {
public extension DualCameraVideoSaveStrategy {
/// Creates a strategy that saves videos to the user's media library using the provided service.
/// - Parameter service: The service responsible for handling photo library operations.
/// - Returns: A configured save strategy for photos.
static func videoLibrary(service: MediaLibraryService) -> VideoSaveStrategy {
static func videoLibrary(service: MediaLibraryService) -> DualCameraVideoSaveStrategy {
.saveToMediaLibrary { [service] url in
try await service.saveVideo(url)
}
Expand Down
22 changes: 8 additions & 14 deletions Sources/DualCameraKit/Screen/DualCameraViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,15 @@ public final class DualCameraViewModel {

let controller: DualCameraControlling
private var recordingTimer: Timer?
private var videoSaveStrategy: VideoSaveStrategy
private var photoSaveStrategy: PhotoSaveStrategy
private var videoSaveStrategy: DualCameraVideoSaveStrategy
private var photoSaveStrategy: DualCameraPhotoSaveStrategy

init(
dualCameraController: DualCameraControlling,
public init(
dualCameraController: DualCameraControlling = CurrentDualCameraEnvironment.dualCameraController,
layout: DualCameraLayout = .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing),
videoRecorderMode: DualCameraVideoRecordingMode = .cpuBased(.init(photoCaptureMode: .fullScreen)),
videoSaveStrategy: VideoSaveStrategy = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService),
photoSaveStrategy: PhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService)
videoSaveStrategy: DualCameraVideoSaveStrategy = .videoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService),
photoSaveStrategy: DualCameraPhotoSaveStrategy = .photoLibrary(service: CurrentDualCameraEnvironment.mediaLibraryService)

) {
self.controller = dualCameraController
Expand Down Expand Up @@ -219,16 +219,10 @@ public final class DualCameraViewModel {

extension DualCameraViewModel {
public static func `default`() -> DualCameraViewModel {
#if targetEnvironment(simulator)
let dualCameraController = DualCameraMockController()
#else
let dualCameraController = DualCameraController()
#endif

return DualCameraViewModel(
dualCameraController: dualCameraController
dualCameraController: CurrentDualCameraEnvironment.dualCameraController
)
}
}
}

// MARK: - UI State Helpers
Expand Down
189 changes: 189 additions & 0 deletions Tests/DualCameraKitTests/DualCameraViewModelTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import XCTest
@testable import DualCameraKit

@MainActor
final class DualCameraViewModelTests: XCTestCase {

// MARK: - Initialization Tests

func test_init_withDefaultParams_setsDefaultValues() async {
let mockController = MockDualCameraController()
CurrentDualCameraEnvironment.dualCameraController = mockController

let viewModel = DualCameraViewModel()

XCTAssertEqual(viewModel.viewState, .loading)
XCTAssertEqual(viewModel.configuration.layout, .piP(miniCamera: .front, miniCameraPosition: .bottomTrailing))
XCTAssertEqual(viewModel.videoRecorderType, .cpuBased(.init(photoCaptureMode: .fullScreen)))
XCTAssertIdentical(viewModel.controller as? MockDualCameraController, mockController)
}

func test_init_withCustomParams_setsCustomValues() {
let mockController = MockDualCameraController()
let customLayout: DualCameraLayout = .sideBySide
let customRecorderMode: DualCameraVideoRecordingMode = .replayKit()

let viewModel = DualCameraViewModel(
dualCameraController: mockController,
layout: customLayout,
videoRecorderMode: customRecorderMode
)


XCTAssertEqual(viewModel.configuration.layout, customLayout)
XCTAssertEqual(viewModel.videoRecorderType, customRecorderMode)
}

// MARK: - Lifecycle Tests

func test_onAppear_startsSession() async {
let mockController = MockDualCameraController()
let viewModel = DualCameraViewModel(dualCameraController: mockController)

viewModel.onAppear(containerSize: CGSize(width: 390, height: 844))

// Need to wait for the async Task to complete
await Task.yield()

XCTAssertTrue(mockController.sessionStarted)
XCTAssertEqual(viewModel.configuration.containerSize, CGSize(width: 390, height: 844))
XCTAssertEqual(viewModel.viewState, .ready)
}

func test_onAppear_handlesError() async {
let mockController = MockDualCameraController()
mockController.shouldFailStartSession = true
let viewModel = DualCameraViewModel(dualCameraController: mockController)

viewModel.onAppear(containerSize: CGSize(width: 390, height: 844))

// Need to wait for the async Task to complete
await Task.yield()

XCTAssertEqual(viewModel.viewState, .error(DualCameraError.unknownError))
XCTAssertNotNil(viewModel.alert)
}

func test_onDisappear_stopsSession() async {
let mockController = MockDualCameraController()
let viewModel = DualCameraViewModel(dualCameraController: mockController)

viewModel.onAppear(containerSize: CGSize(width: 390, height: 844))
await Task.yield()

viewModel.onDisappear()

XCTAssertTrue(mockController.sessionStopped)
}

}

// MARK: - Test Helpers

class MockDualCameraController: DualCameraControlling {
var sessionStarted = false
var sessionStopped = false
var videoRecordingStarted = false
var videoRecordingStopped = false
var shouldFailStartSession = false
var shouldFailCaptureScreen = false

var mockCapturedImage = UIImage()
var mockVideoOutputURL = URL(string: "file:///tmp/test.mp4")!

var frontCameraStream: AsyncStream<PixelBufferWrapper> {
AsyncStream { continuation in
continuation.finish()
}
}

var backCameraStream: AsyncStream<PixelBufferWrapper> {
AsyncStream { continuation in
continuation.finish()
}
}

func getRenderer(for source: DualCameraSource) -> CameraRenderer {
MockCameraRenderer()
}

func startSession() async throws {
if shouldFailStartSession {
throw DualCameraError.unknownError
}
sessionStarted = true
}

func stopSession() {
sessionStopped = true
}

var photoCapturer: any DualCameraPhotoCapturing {
MockPhotoCapturer(mockImage: mockCapturedImage, shouldFail: shouldFailCaptureScreen)
}

func captureRawPhotos() async throws -> (front: UIImage, back: UIImage) {
if shouldFailCaptureScreen {
throw DualCameraError.captureFailure(.noFrameAvailable)
}
return (front: mockCapturedImage, back: mockCapturedImage)
}

func captureCurrentScreen(mode: DualCameraPhotoCaptureMode) async throws -> UIImage {
if shouldFailCaptureScreen {
throw DualCameraError.captureFailure(.noFrameAvailable)
}
return mockCapturedImage
}

var videoRecorder: (any DualCameraVideoRecording)?

func setVideoRecorder(_ recorder: any DualCameraVideoRecording) async throws {
videoRecorder = recorder
}

func startVideoRecording(mode: DualCameraVideoRecordingMode) async throws {
videoRecordingStarted = true
}

func stopVideoRecording() async throws -> URL {
videoRecordingStopped = true
return mockVideoOutputURL
}
}

class MockCameraRenderer: CameraRenderer {
func update(with buffer: CVPixelBuffer) {
// No-op for tests
}

func captureCurrentFrame() async throws -> UIImage {
return UIImage()
}
}

class MockPhotoCapturer: DualCameraPhotoCapturing {
private let mockImage: UIImage
private let shouldFail: Bool

init(mockImage: UIImage, shouldFail: Bool = false) {
self.mockImage = mockImage
self.shouldFail = shouldFail
}

func captureRawPhotos(
frontRenderer: CameraRenderer, backRenderer: CameraRenderer
) async throws -> (front: UIImage, back: UIImage) {
if shouldFail {
throw DualCameraError.captureFailure(.noFrameAvailable)
}
return (front: mockImage, back: mockImage)
}

func captureCurrentScreen(mode: DualCameraPhotoCaptureMode) async throws -> UIImage {
if shouldFail {
throw DualCameraError.captureFailure(.noFrameAvailable)
}
return mockImage
}
}
16 changes: 8 additions & 8 deletions Tests/DualCameraKitTests/MediaSaveStrategyTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,61 +5,61 @@
func test_customPhotoStrategy() async throws {
let imageBox = TestBox<UIImage>()
let testImage = UIImage()

Check warning on line 8 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
let strategy = PhotoSaveStrategy.custom { image in
let strategy = DualCameraPhotoSaveStrategy.custom { image in
await imageBox.set(image)
}

try await strategy.save(testImage)

let savedImage = await imageBox.get()
XCTAssertEqual(savedImage, testImage)
}

Check warning on line 18 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
func test_customVideoStrategy() async throws {
let urlBox = TestBox<URL>()
let testUrl = URL.mock()

Check warning on line 22 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
let strategy = VideoSaveStrategy.custom { url in
let strategy = DualCameraVideoSaveStrategy.custom { url in
await urlBox.set(url)
}

try await strategy.save(testUrl)

Check warning on line 28 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
let savedURL = await urlBox.get()
XCTAssertEqual(savedURL, testUrl)
}

Check warning on line 32 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
func test_mediaLibraryVideoStrategy() async throws {
let urlBox = TestBox<URL>()
let testUrl = URL.mock()
let mock = MediaLibraryService.test(saveVideo: { url in
let mock = MediaLibraryService.test(saveVideo: { url in
await urlBox.set(url)
})

Check warning on line 39 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
let strategy = VideoSaveStrategy.saveToMediaLibrary(mock.saveVideo)
let strategy = DualCameraVideoSaveStrategy.saveToMediaLibrary(mock.saveVideo)
try await strategy.save(testUrl)
let savedUrl = await urlBox.get()
XCTAssertEqual(savedUrl, testUrl)
}

Check warning on line 45 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
func test_mediaLibraryPhotoStrategy() async throws {
let imageBox = TestBox<UIImage>()
let testImage = UIImage()
let mock = MediaLibraryService.test(saveImage: { image in
let mock = MediaLibraryService.test(saveImage: { image in
await imageBox.set(image)
})

Check warning on line 52 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
let strategy = PhotoSaveStrategy.saveToMediaLibrary(mock.saveImage)
let strategy = DualCameraPhotoSaveStrategy.saveToMediaLibrary(mock.saveImage)
try await strategy.save(testImage)
let savedImage = await imageBox.get()
XCTAssertEqual(savedImage, testImage)
}

Check warning on line 58 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Trailing Whitespace Violation: Lines should not have trailing whitespace (trailing_whitespace)
func test_mediaLibraryStrategy_unimplementedFails() async {
let strategy = PhotoSaveStrategy.saveToMediaLibrary(MediaLibraryService.failing.saveImage)
let strategy = DualCameraPhotoSaveStrategy.saveToMediaLibrary(MediaLibraryService.failing.saveImage)
await XCTAssertThrowsError(ofType: MediaLibraryError.self, try await strategy.save(UIImage()) )
}


Check warning on line 64 in Tests/DualCameraKitTests/MediaSaveStrategyTests.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Vertical Whitespace Violation: Limit vertical whitespace to a single empty line; currently 2 (vertical_whitespace)
}
1 change: 0 additions & 1 deletion Tests/Helpers/TestBox.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

actor TestBox<T> {
private(set) var value: T?

Expand Down
Loading