diff --git a/iOS/Projects/App/WeTri/Sources/AScene/ViewController/AViewController.swift b/iOS/Projects/App/WeTri/Sources/AScene/ViewController/AViewController.swift index 836a2bb0..49d81e80 100644 --- a/iOS/Projects/App/WeTri/Sources/AScene/ViewController/AViewController.swift +++ b/iOS/Projects/App/WeTri/Sources/AScene/ViewController/AViewController.swift @@ -8,7 +8,7 @@ import UIKit -class AViewController: UITabBarController { +class AViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() diff --git a/iOS/Projects/App/WeTri/Sources/Application/SceneDelegate.swift b/iOS/Projects/App/WeTri/Sources/Application/SceneDelegate.swift index 4bf71d37..251fe84e 100644 --- a/iOS/Projects/App/WeTri/Sources/Application/SceneDelegate.swift +++ b/iOS/Projects/App/WeTri/Sources/Application/SceneDelegate.swift @@ -12,13 +12,15 @@ import UIKit final class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? + private var coordinating: AppCoordinating? func scene(_ scene: UIScene, willConnectTo _: UISceneSession, options _: UIScene.ConnectionOptions) { guard let windowScene = scene as? UIWindowScene else { return } let navigationController = UINavigationController() window = UIWindow(windowScene: windowScene) window?.rootViewController = navigationController - let coordinator = RecordFeatureCoordinator(navigationController: navigationController) + let coordinator = AppCoordinator(navigationController: navigationController) + coordinating = coordinator coordinator.start() window?.makeKeyAndVisible() } diff --git a/iOS/Projects/App/WeTri/Sources/CScene/ViewController/CViewController.swift b/iOS/Projects/App/WeTri/Sources/CScene/ViewController/CViewController.swift index e711ffa4..84cbd5e3 100644 --- a/iOS/Projects/App/WeTri/Sources/CScene/ViewController/CViewController.swift +++ b/iOS/Projects/App/WeTri/Sources/CScene/ViewController/CViewController.swift @@ -6,11 +6,182 @@ // Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. // +import Combine +import Log +import Trinet import UIKit -class CViewController: UITabBarController { +// MARK: - ParticipantsCodable + +struct ParticipantsCodable: Codable { + let id: UUID + let nickname: String + let message: String + + init(message: String) { + id = .init() + self.message = message + nickname = "MyNickname" + } +} + +// MARK: - CViewController + +final class CViewController: UIViewController { + private let returnSubject = PassthroughSubject() + private let repository = TestRepository(session: URLSession.shared) + private var subscriptions = Set() + + private let textField = UITextField() + + private let textView: UITextView = .init() + override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .red + + textView.isUserInteractionEnabled = false + view.addSubview(textField) + view.addSubview(textView) + textField.placeholder = "입력해주세요." + textField.borderStyle = .roundedRect + textField.delegate = self + + textView.textColor = .white + textView.backgroundColor = .black + textField.translatesAutoresizingMaskIntoConstraints = false + textView.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .white + let safeArea = view.safeAreaLayoutGuide + let recognizer = UITapGestureRecognizer(target: self, action: #selector(tapped)) + view.addGestureRecognizer(recognizer) + NSLayoutConstraint.activate( + [ + textField.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 20), + textField.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + textField.widthAnchor.constraint(equalToConstant: 240), + textField.heightAnchor.constraint(equalToConstant: 40), + + textView.topAnchor.constraint(equalTo: safeArea.centerYAnchor), + textView.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + textView.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), + ] + ) + bind() + } + + func bind() { + returnSubject + .flatMap(repository.sendMyHealth(healthData:)) + .sink { _ in + Log.make().error("Completion!!!!!!") + } receiveValue: { _ in + Log.make().debug("Yay! Send successfully!") + } + .store(in: &subscriptions) + + repository.fetchParticipantsRealTime() + .receive(on: RunLoop.main) + .sink { _ in + Log.make().error("Socket Receive Completion!!!!") + } receiveValue: { [weak self] dataString in + self?.textView.text = [self!.textView.text + dataString].joined(separator: "\n") + Log.make().debug("\(dataString)") + } + .store(in: &subscriptions) + } + + @objc + private func tapped() { + textField.resignFirstResponder() + } +} + +// MARK: UITextFieldDelegate + +extension CViewController: UITextFieldDelegate { + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + returnSubject.send(textField.text!) + + textField.text = "" + return true + } +} + +// MARK: - TestRepositoryRepresentable + +protocol TestRepositoryRepresentable { + func fetchParticipantsRealTime() -> AnyPublisher + func sendMyHealth(healthData: String) -> AnyPublisher +} + +// MARK: - TestRepository + +struct TestRepository { + private let provider: TNSocketProvider + + private var task: Task? + + private let subject: PassthroughSubject = .init() + + init(session: URLSessionWebSocketProtocol) { + provider = .init(session: session, endPoint: .init()) + task = receiveParticipantsData() + } + + private func receiveParticipantsData() -> Task { + return Task { + Log.make(with: .network).debug("receive Ready") + while let data = try await provider.receive() { + switch data { + case let .string(string): + Log.make(with: .network).debug("received \(string)") + subject.send(string) + default: + fatalError("절대 여기 와서는 안 됨") + } + } + Log.make().fault("You can't enter this line") + } + } +} + +// MARK: TestRepositoryRepresentable + +extension TestRepository: TestRepositoryRepresentable { + func fetchParticipantsRealTime() -> AnyPublisher { + subject.eraseToAnyPublisher() + } + + func sendMyHealth(healthData: String) -> AnyPublisher { + Future { promise in + Task { + do { + try await provider.send(model: ParticipantsCodable(message: healthData)) + promise(.success(true)) + } catch { + promise(.failure(error)) + } + } + } + .eraseToAnyPublisher() } } + +// MARK: - TestEndPoint + +struct TestEndPoint: TNEndPoint { + let baseURL: String = Bundle.main.infoDictionary?["SocketURL"] as? String ?? "" + + let path: String = "" + + let method: TNMethod = .get + + let query: Encodable? = nil + + let body: Encodable? = nil + + let headers: TNHeaders = [ + .authorization(bearer: ""), + ] +} diff --git a/iOS/Projects/Core/Network/Sources/MockWebSocketSession.swift b/iOS/Projects/Core/Network/Sources/MockWebSocketSession.swift new file mode 100644 index 00000000..4dade259 --- /dev/null +++ b/iOS/Projects/Core/Network/Sources/MockWebSocketSession.swift @@ -0,0 +1,44 @@ +// +// MockWebSocketSession.swift +// Trinet +// +// Created by 홍승현 on 11/29/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import Foundation + +// MARK: - MockWebSocketTask + +public final class MockWebSocketTask: WebSocketTaskProtocol { + private var sentMessage: URLSessionWebSocketTask.Message? + private var receiveContinuation: CheckedContinuation? + + public func send(_ message: URLSessionWebSocketTask.Message) async throws { + sentMessage = message + receiveContinuation?.resume(returning: message) + } + + public func receive() async throws -> URLSessionWebSocketTask.Message { + if let receivedMessage = sentMessage { + sentMessage = nil + return receivedMessage + } + + return await withCheckedContinuation { continuation in + receiveContinuation = continuation + } + } + + public func resume() {} +} + +// MARK: - MockWebSocketSession + +public struct MockWebSocketSession: URLSessionWebSocketProtocol { + var webSocketTask: MockWebSocketTask = .init() + + public func webSocketTask(with _: URLRequest) -> WebSocketTaskProtocol { + return webSocketTask + } +} diff --git a/iOS/Projects/Core/Network/Sources/TNSocketProvider.swift b/iOS/Projects/Core/Network/Sources/TNSocketProvider.swift new file mode 100644 index 00000000..9900fe58 --- /dev/null +++ b/iOS/Projects/Core/Network/Sources/TNSocketProvider.swift @@ -0,0 +1,51 @@ +// +// TNSocketProvider.swift +// Trinet +// +// Created by 홍승현 on 11/29/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import Foundation + +// MARK: - TNSocketProvidable + +public protocol TNSocketProvidable { + func send(model: Model) async throws + func receive() async throws -> URLSessionWebSocketTask.Message? +} + +// MARK: - TNSocketProvider + +public struct TNSocketProvider: TNSocketProvidable { + private let session: URLSessionWebSocketProtocol + private var task: WebSocketTaskProtocol? + private let jsonEncoder: JSONEncoder = .init() + + public init(session: URLSessionWebSocketProtocol = URLSession.shared, endPoint: EndPoint) { + self.session = session + task = try? session.webSocketTask(with: endPoint.request()) + task?.resume() + } + + public func send(model: some Codable) async throws { + let frame = WebSocketFrame(data: model) + try await task?.send(.data(jsonEncoder.encode(frame))) + } + + public func receive() async throws -> URLSessionWebSocketTask.Message? { + return try await task?.receive() + } +} + +// MARK: - WebSocketFrame + +struct WebSocketFrame: Codable { + let event: String + let data: T + + init(event: String = "workout_session", data: T) { + self.event = event + self.data = data + } +} diff --git a/iOS/Projects/Core/Network/Sources/URLSessionWebSocketProtocol.swift b/iOS/Projects/Core/Network/Sources/URLSessionWebSocketProtocol.swift new file mode 100644 index 00000000..b269a7c4 --- /dev/null +++ b/iOS/Projects/Core/Network/Sources/URLSessionWebSocketProtocol.swift @@ -0,0 +1,24 @@ +// +// URLSessionWebSocketProtocol.swift +// Trinet +// +// Created by 홍승현 on 11/29/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import Foundation + +// MARK: - URLSessionWebSocketProtocol + +public protocol URLSessionWebSocketProtocol { + func webSocketTask(with request: URLRequest) -> WebSocketTaskProtocol +} + +// MARK: - URLSession + URLSessionWebSocketProtocol + +extension URLSession: URLSessionWebSocketProtocol { + public func webSocketTask(with request: URLRequest) -> WebSocketTaskProtocol { + let socketTask: URLSessionWebSocketTask = webSocketTask(with: request) + return socketTask + } +} diff --git a/iOS/Projects/Core/Network/Sources/WebSocketTaskProtocol.swift b/iOS/Projects/Core/Network/Sources/WebSocketTaskProtocol.swift new file mode 100644 index 00000000..00268b89 --- /dev/null +++ b/iOS/Projects/Core/Network/Sources/WebSocketTaskProtocol.swift @@ -0,0 +1,23 @@ +// +// WebSocketTaskProtocol.swift +// Trinet +// +// Created by 홍승현 on 11/29/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +import Foundation + +// MARK: - WebSocketTaskProtocol + +public protocol WebSocketTaskProtocol { + func send(_ message: URLSessionWebSocketTask.Message) async throws + + func receive() async throws -> URLSessionWebSocketTask.Message + + func resume() +} + +// MARK: - URLSessionWebSocketTask + WebSocketTaskProtocol + +extension URLSessionWebSocketTask: WebSocketTaskProtocol {} diff --git a/iOS/Projects/Core/Network/Tests/SessionWebSocketProtocolTests.swift b/iOS/Projects/Core/Network/Tests/SessionWebSocketProtocolTests.swift new file mode 100644 index 00000000..1e50571d --- /dev/null +++ b/iOS/Projects/Core/Network/Tests/SessionWebSocketProtocolTests.swift @@ -0,0 +1,56 @@ +// +// SessionWebSocketProtocolTests.swift +// TrinetTests +// +// Created by 홍승현 on 11/29/23. +// Copyright © 2023 kr.codesquad.boostcamp8. All rights reserved. +// + +@testable import Trinet + +import XCTest + +final class SessionWebSocketProtocolTests: XCTestCase { + private var mockSession: MockWebSocketSession? + private var socketProvider: TNSocketProvider? + + struct TestModel: Codable, Equatable { + var id: UUID = .init() + var profileImage: String = "https://hello.world" + var nickname: String = "NickName" + var message: String = "" + } + + override func setUp() { + super.setUp() + let mockSession = MockWebSocketSession() + self.mockSession = mockSession + socketProvider = TNSocketProvider(session: mockSession, endPoint: MockEndPoint()) + } + + override func tearDown() { + mockSession = nil + socketProvider = nil + super.tearDown() + } + + func testSendAndReceive() async throws { + // arrange + let testModel = TestModel() + + // act + Task { + try await self.socketProvider?.send(model: testModel) + } + + // Receive 메서드를 비동기로 호출하여 결과 검증 + let receivedMessage = try await socketProvider?.receive() + guard case let .data(data) = receivedMessage else { + XCTFail("Received message is not of type .data") + return + } + + let receivedModel = try JSONDecoder().decode(WebSocketFrame.self, from: data) + XCTAssertEqual(receivedModel.data, testModel, "Received model does not match sent model") + } +} diff --git a/iOS/Tuist/ProjectDescriptionHelpers/Target+Templates.swift b/iOS/Tuist/ProjectDescriptionHelpers/Target+Templates.swift index 708bd876..ef473b64 100644 --- a/iOS/Tuist/ProjectDescriptionHelpers/Target+Templates.swift +++ b/iOS/Tuist/ProjectDescriptionHelpers/Target+Templates.swift @@ -35,6 +35,7 @@ public extension [Target] { let mergedInfoPlist: [String: Plist.Value] = [ "BaseURL": "$(BASE_URL)", + "SocketURL": "$(SOCKET_URL)", "UILaunchStoryboardName": "LaunchScreen", "UIApplicationSceneManifest": [ "UIApplicationSupportsMultipleScenes": false, @@ -122,7 +123,7 @@ public extension [Target] { resources: ResourceFileElements? = nil ) -> [Target] { - let mergedInfoPlist: [String: Plist.Value] = ["BaseURL": "$(BASE_URL)"].merging(infoPlist) { _, new in + let mergedInfoPlist: [String: Plist.Value] = ["BaseURL": "$(BASE_URL)", "SocketURL": "$(SOCKET_URL)"].merging(infoPlist) { _, new in new } @@ -203,7 +204,7 @@ public extension [Target] { settings: Settings? = nil ) -> [Target] { - let mergedInfoPlist: [String: Plist.Value] = ["BaseURL": "$(BASE_URL)"].merging(infoPlist) { _, new in + let mergedInfoPlist: [String: Plist.Value] = ["BaseURL": "$(BASE_URL)", "SocketURL": "$(SOCKET_URL)"].merging(infoPlist) { _, new in new }