diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 6711fc9b..ab17f0f4 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -7,6 +7,12 @@ objects = { /* Begin PBXBuildFile section */ + AC5200013012000000A5BBA3 /* AuthVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5200003012000000A5BBA3 /* AuthVerificationView.swift */; }; + AC5200023012000000A5BBA3 /* AuthVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5200003012000000A5BBA3 /* AuthVerificationView.swift */; }; + AC5200043012000000A5BBA3 /* AuthVerificationMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5200033012000000A5BBA3 /* AuthVerificationMock.swift */; }; + AC5200053012000000A5BBA3 /* AuthVerificationMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5200033012000000A5BBA3 /* AuthVerificationMock.swift */; }; + AC5100013012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5100003012000000A5BBA3 /* AuthContentSettingsSheet.swift */; }; + AC5100023012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC5100003012000000A5BBA3 /* AuthContentSettingsSheet.swift */; }; 00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; }; 042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; }; 0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; }; @@ -2202,6 +2208,9 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + AC5200003012000000A5BBA3 /* AuthVerificationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthVerificationView.swift; sourceTree = ""; }; + AC5200033012000000A5BBA3 /* AuthVerificationMock.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthVerificationMock.swift; sourceTree = ""; }; + AC5100003012000000A5BBA3 /* AuthContentSettingsSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AuthContentSettingsSheet.swift; sourceTree = ""; }; 002435A7145D59CCCA8F76D1 /* CreatorChannelFanTalkApi.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CreatorChannelFanTalkApi.swift; sourceTree = ""; }; 0BBDC3ECBBE049EAA4057D3A /* PostChannelDonationRequest.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PostChannelDonationRequest.swift; sourceTree = ""; }; 0EC05859619BDC03056911E7 /* MainTab.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MainTab.swift; sourceTree = ""; }; @@ -3355,6 +3364,8 @@ 25C5D0734F54C0ED7B7BD462 /* Modal */ = { isa = PBXGroup; children = ( + AC5200003012000000A5BBA3 /* AuthVerificationView.swift */, + AC5100003012000000A5BBA3 /* AuthContentSettingsSheet.swift */, 25C5D0714F54C0ED7B7BD462 /* SodaV2ActionModal.swift */, ); path = Modal; @@ -6631,6 +6642,7 @@ F18179AD98929A1E018058E0 /* V2 */ = { isa = PBXGroup; children = ( + AC5200063012000000A5BBA3 /* Auth */, 976A594BED288A98C0EEA03C /* Component */, D979B1B9166521052893A897 /* Main */, CC0100242FF0100000A5BBA3 /* CreatorChannel */, @@ -6639,6 +6651,14 @@ path = V2; sourceTree = ""; }; + AC5200063012000000A5BBA3 /* Auth */ = { + isa = PBXGroup; + children = ( + AC5200033012000000A5BBA3 /* AuthVerificationMock.swift */, + ); + path = Auth; + sourceTree = ""; + }; F4F9FC33C494BBFD8CE96749 /* Models */ = { isa = PBXGroup; children = ( @@ -7003,6 +7023,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AC5200013012000000A5BBA3 /* AuthVerificationView.swift in Sources */, + AC5200043012000000A5BBA3 /* AuthVerificationMock.swift in Sources */, + AC5100013012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */, A4F100000000000000000001 /* LottieRefreshIndicator.swift in Sources */, A4F100000000000000000003 /* LottieRefreshableScrollView.swift in Sources */, 400EDE592B31D2D7008E37B4 /* CreatorCommunityApi.swift in Sources */, @@ -8138,6 +8161,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AC5200023012000000A5BBA3 /* AuthVerificationView.swift in Sources */, + AC5200053012000000A5BBA3 /* AuthVerificationMock.swift in Sources */, + AC5100023012000000A5BBA3 /* AuthContentSettingsSheet.swift in Sources */, A4F100000000000000000002 /* LottieRefreshIndicator.swift in Sources */, A4F100000000000000000004 /* LottieRefreshableScrollView.swift in Sources */, 400EDE5A2B31D2D7008E37B4 /* CreatorCommunityApi.swift in Sources */, @@ -9340,6 +9366,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 959B90DBB4BB5911AB10C442 /* Pods-SodaLive-dev.debug.xcconfig */; buildSettings = { + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) AUTH_VERIFICATION_MOCK"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "SodaLive-dev.entitlements"; @@ -9380,6 +9407,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = B108032F3DC7110CBFD04130 /* Pods-SodaLive-dev.release.xcconfig */; buildSettings = { + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) AUTH_VERIFICATION_MOCK"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "SodaLive-dev.entitlements"; diff --git a/SodaLive/Sources/Chat/ChatTabView.swift b/SodaLive/Sources/Chat/ChatTabView.swift index 3f5ac4a3..8ee33a17 100644 --- a/SodaLive/Sources/Chat/ChatTabView.swift +++ b/SodaLive/Sources/Chat/ChatTabView.swift @@ -7,7 +7,6 @@ import SwiftUI import Bootpay -import BootpayUI struct ChatTabView: View { @AppStorage("token") private var token: String = UserDefaults.string(forKey: UserDefaultsKey.token) @AppStorage("auth") private var auth: Bool = UserDefaults.bool(forKey: UserDefaultsKey.auth) @@ -159,33 +158,30 @@ struct ChatTabView: View { payload.orderName = "본인인증" payload.authenticationId = "\(UserDefaults.string(forKey: .nickname))__\(String(NSTimeIntervalSince1970))" } - .fullScreenCover(isPresented: $isShowAuthView) { - BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) - .onConfirm { _ in - true - } - .onCancel { _ in + .fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) { + AuthVerificationView( + payload: payload, + onCancel: { isShowAuthView = false - } - .onError { _ in + }, + onError: { AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError AppState.shared.isShowErrorPopup = true isShowAuthView = false - } - .onDone { - DEBUG_LOG("onDone: \($0)") + }, + onDone: { + let action = pendingAction mypageViewModel.authVerify($0) { auth = true - isShowAuthView = false - if let action = pendingAction { - pendingAction = nil - action() - } + pendingAction = nil + action?() } - } - .onClose { + isShowAuthView = false + }, + onClose: { isShowAuthView = false } + ) } if isShowAuthConfirmView { @@ -206,6 +202,7 @@ struct ChatTabView: View { ) } } + .authContentSettings(viewModel: mypageViewModel) } } diff --git a/SodaLive/Sources/Extensions/UserDefaultsExtension.swift b/SodaLive/Sources/Extensions/UserDefaultsExtension.swift index 10cc8922..89350c34 100644 --- a/SodaLive/Sources/Extensions/UserDefaultsExtension.swift +++ b/SodaLive/Sources/Extensions/UserDefaultsExtension.swift @@ -43,6 +43,14 @@ extension UserDefaults { } static func set(_ value: String, forKey key: UserDefaultsKey) { + #if AUTH_VERIFICATION_MOCK + if key == .token { + AuthVerificationMock.shared.resetSession { + UserDefaults.standard.set(value, forKey: key.rawValue) + } + return + } + #endif let key = key.rawValue UserDefaults.standard.set(value, forKey: key) } @@ -78,6 +86,12 @@ extension UserDefaults { } static func reset() { + #if AUTH_VERIFICATION_MOCK + AuthVerificationMock.shared.resetSession { + UserDefaultsKey.allCases.filter { $0 != .pushToken }.forEach { UserDefaults.standard.removeObject(forKey: $0.rawValue) } + } + #else UserDefaultsKey.allCases.filter { $0 != .pushToken }.forEach { UserDefaults.standard.removeObject(forKey: $0.rawValue) } + #endif } } diff --git a/SodaLive/Sources/Home/HomeTabView.swift b/SodaLive/Sources/Home/HomeTabView.swift index ee57cb49..a752794b 100644 --- a/SodaLive/Sources/Home/HomeTabView.swift +++ b/SodaLive/Sources/Home/HomeTabView.swift @@ -7,7 +7,6 @@ import SwiftUI import Bootpay -import BootpayUI struct HomeTabView: View { @StateObject var viewModel = HomeTabViewModel() @@ -479,36 +478,34 @@ struct HomeTabView: View { viewModel.fetchData() } - .fullScreenCover(isPresented: $isShowAuthView) { - BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) - .onConfirm { _ in - true - } - .onCancel { _ in + .fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) { + AuthVerificationView( + payload: payload, + onCancel: { isShowAuthView = false - } - .onError { _ in + }, + onError: { AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError AppState.shared.isShowErrorPopup = true isShowAuthView = false - } - .onDone { - DEBUG_LOG("onDone: \($0)") + }, + onDone: { + let action = pendingAction mypageViewModel.authVerify($0) { auth = true - isShowAuthView = false - if let action = pendingAction { - pendingAction = nil - action() - } + pendingAction = nil + action?() } - } - .onClose { + isShowAuthView = false + }, + onClose: { isShowAuthView = false } + ) } } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .authContentSettings(viewModel: mypageViewModel) } } diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 7b893a02..1ffb5009 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -1653,6 +1653,46 @@ enum I18n { } enum Content { + static var authSensitiveContentOnDescription: String { + pick( + ko: "성인인증 완료 후 성인 콘텐츠를 볼 수 있어요", + en: "You can view adult content after age verification.", + ja: "年齢認証完了後、成人向けコンテンツを表示できます" + ) + } + + static var authSensitiveContentOffDescription: String { + pick( + ko: "성인 콘텐츠가 목록과 검색에 노출되지 않아요", + en: "Adult content will not appear in lists or search.", + ja: "成人向けコンテンツは一覧や検索に表示されません" + ) + } + + static var preferredContent: String { + pick(ko: "선호 콘텐츠", en: "Preferred content", ja: "好みのコンテンツ") + } + + static var authSettingsGuide: String { + pick( + ko: "마이 > 설정 > 콘텐츠 보기 설정에서 언제든 바꿀 수 있어요", + en: "You can change this anytime in My > Settings > Content view settings.", + ja: "マイ > 設定 > コンテンツ表示設定でいつでも変更できます" + ) + } + + static var confirmAuthSettings: String { + pick(ko: "설정 완료", en: "Confirm settings", ja: "設定を完了") + } + + static var selectedPreference: String { + pick(ko: "선택됨", en: "Selected", ja: "選択済み") + } + + static var unselectedPreference: String { + pick(ko: "선택 안 됨", en: "Not selected", ja: "未選択") + } + static var sensitiveContentTitle: String { pick(ko: "민감한 콘텐츠 보기", en: "Show sensitive content", ja: "センシティブなコンテンツ表示") } diff --git a/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift b/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift index 04ff111a..9c0c342d 100644 --- a/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift +++ b/SodaLive/Sources/Live/Now/All/LiveNowAllView.swift @@ -8,7 +8,6 @@ import SwiftUI import RefreshableScrollView import Bootpay -import BootpayUI struct LiveNowAllView: View { @@ -126,35 +125,33 @@ struct LiveNowAllView: View { viewModel.getLiveNowList() } - .fullScreenCover(isPresented: $isShowAuthView) { - BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) - .onConfirm { _ in - true - } - .onCancel { _ in + .fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) { + AuthVerificationView( + payload: payload, + onCancel: { isShowAuthView = false - } - .onError { _ in + }, + onError: { AppState.shared.errorMessage = I18n.Main.Auth.authenticationError AppState.shared.isShowErrorPopup = true isShowAuthView = false - } - .onDone { - DEBUG_LOG("onDone: \($0)") + }, + onDone: { + let action = pendingAction mypageViewModel.authVerify($0) { auth = true - isShowAuthView = false - if let action = pendingAction { - pendingAction = nil - action() - } + pendingAction = nil + action?() } - } - .onClose { + isShowAuthView = false + }, + onClose: { isShowAuthView = false } + ) } .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .authContentSettings(viewModel: mypageViewModel) } private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) { diff --git a/SodaLive/Sources/Main/Home/HomeView.swift b/SodaLive/Sources/Main/Home/HomeView.swift index 9469ad6f..348525b8 100644 --- a/SodaLive/Sources/Main/Home/HomeView.swift +++ b/SodaLive/Sources/Main/Home/HomeView.swift @@ -10,7 +10,6 @@ import SwiftUI import Firebase import Kingfisher import Bootpay -import BootpayUI struct HomeView: View { @StateObject var viewModel = HomeViewModel() @@ -296,33 +295,30 @@ struct HomeView: View { } } .edgesIgnoringSafeArea(.bottom) - .fullScreenCover(isPresented: $isShowAuthView) { - BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) - .onConfirm { _ in - true - } - .onCancel { _ in + .fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) { + AuthVerificationView( + payload: payload, + onCancel: { isShowAuthView = false - } - .onError { _ in + }, + onError: { AppState.shared.errorMessage = I18n.Main.Auth.authenticationError AppState.shared.isShowErrorPopup = true isShowAuthView = false - } - .onDone { - DEBUG_LOG("onDone: \($0)") + }, + onDone: { + let action = pendingAction mypageViewModel.authVerify($0) { auth = true - isShowAuthView = false - if let action = pendingAction { - pendingAction = nil - action() - } + pendingAction = nil + action?() } - } - .onClose { + isShowAuthView = false + }, + onClose: { isShowAuthView = false } + ) } .valueChanged(value: appState.pushRoomId) { value in guard value > 0 else { @@ -463,6 +459,7 @@ struct HomeView: View { autohideIn: 2 ) } + .authContentSettings(viewModel: mypageViewModel) } private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) { diff --git a/SodaLive/Sources/MyPage/Auth/AuthRepository.swift b/SodaLive/Sources/MyPage/Auth/AuthRepository.swift index 668fab38..9a844b76 100644 --- a/SodaLive/Sources/MyPage/Auth/AuthRepository.swift +++ b/SodaLive/Sources/MyPage/Auth/AuthRepository.swift @@ -13,8 +13,18 @@ import Moya final class AuthRepository { private let api = MoyaProvider() - func authCertificate(receiptId: String) -> AnyPublisher { - return api.requestPublisher(.auth(request: AuthVerifyRequest(receiptId: receiptId))) + func authCertificate(request: AuthVerifyRequest) -> AnyPublisher { + #if AUTH_VERIFICATION_MOCK + let requestToken = UserDefaults.string(forKey: .token) + guard let data = AuthVerificationMock.shared.confirm(request: request, requestToken: requestToken) else { + return Fail(error: MoyaError.statusCode(Response(statusCode: 401, data: Data()))) + .eraseToAnyPublisher() + } + return Just(Response(statusCode: 200, data: data)) + .setFailureType(to: MoyaError.self) + .eraseToAnyPublisher() + #else + return api.requestPublisher(.auth(request: request)) + #endif } } - diff --git a/SodaLive/Sources/MyPage/Auth/AuthVerifyRequest.swift b/SodaLive/Sources/MyPage/Auth/AuthVerifyRequest.swift index 3e4004a5..e49cec08 100644 --- a/SodaLive/Sources/MyPage/Auth/AuthVerifyRequest.swift +++ b/SodaLive/Sources/MyPage/Auth/AuthVerifyRequest.swift @@ -9,8 +9,12 @@ import Foundation struct AuthVerifyRequest: Encodable { let receiptId: String + let isAdultContentVisible: Bool + let contentType: ContentType enum CodingKeys : String, CodingKey { case receiptId = "receipt_id" + case isAdultContentVisible + case contentType } } diff --git a/SodaLive/Sources/MyPage/MyPageView.swift b/SodaLive/Sources/MyPage/MyPageView.swift index 68a64d66..1bd2a125 100644 --- a/SodaLive/Sources/MyPage/MyPageView.swift +++ b/SodaLive/Sources/MyPage/MyPageView.swift @@ -8,7 +8,6 @@ import SwiftUI import Bootpay -import BootpayUI import Kingfisher import RefreshableScrollView @@ -33,28 +32,23 @@ struct MyPageView: View { BaseView(isLoading: $viewModel.isLoading) { if !token.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && viewModel.isShowAuthView { - BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) - .onConfirm { - DEBUG_LOG("onConfirm: \($0)") - return true - } - .onCancel { - DEBUG_LOG("onCancel: \($0)") - } - .onError { - DEBUG_LOG("onError: \($0)") + AuthVerificationView( + payload: payload, + onCancel: {}, + onError: { viewModel.errorMessage = I18n.Main.Auth.authenticationError viewModel.isShowPopup = true viewModel.isShowAuthView = false - } - .onDone { - DEBUG_LOG("onDone: \($0)") + }, + onDone: { viewModel.authVerify($0) - } - .onClose { - DEBUG_LOG("onClose") + viewModel.isShowAuthView = false + viewModel.presentAuthContentSettings() + }, + onClose: { viewModel.isShowAuthView = false } + ) } else { VStack(spacing: 0) { // Header @@ -180,7 +174,7 @@ struct MyPageView: View { } } } - .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .authContentSettings(viewModel: viewModel) .onAppear { payload.applicationId = BOOTPAY_APP_ID payload.price = 0 diff --git a/SodaLive/Sources/MyPage/MyPageViewModel.swift b/SodaLive/Sources/MyPage/MyPageViewModel.swift index ea581261..d12a79b3 100644 --- a/SodaLive/Sources/MyPage/MyPageViewModel.swift +++ b/SodaLive/Sources/MyPage/MyPageViewModel.swift @@ -14,6 +14,9 @@ final class MyPageViewModel: ObservableObject { private let authRepository = AuthRepository() private let noticeRepository = NoticeRepository() private var subscription = Set() + private var pendingAuthReceiptId: String? + private var pendingAuthRequest: AuthVerifyRequest? + private var pendingAuthSuccess: (() -> Void)? @Published var latestNotice: NoticeItem? = nil @@ -28,6 +31,16 @@ final class MyPageViewModel: ObservableObject { @Published var isShowPopup = false @Published var isLoading = false @Published var isShowAuthView = false + @Published var isShowAuthContentSettings = false + @Published var authIsAdultContentVisible = true { + didSet { + if !authIsAdultContentVisible { + authContentType = .ALL + } + } + } + @Published var authContentType: ContentType = .ALL + @Published private(set) var isSubmittingAuth = false func getMypage() { isLoading = true @@ -73,61 +86,164 @@ final class MyPageViewModel: ObservableObject { } func authVerify(_ data: [String: Any], onSuccess: (() -> Void)? = nil) { - isLoading = true - - let _data = data["data"] as? [String: Any] - - if let data = _data { - let receiptId = data["receipt_id"] as! String - - authRepository.authCertificate(receiptId: receiptId) - .sink { result in - switch result { - case .finished: - DEBUG_LOG("finish") - case .failure(let error): - ERROR_LOG(error.localizedDescription) - } - } receiveValue: { [unowned self] response in - self.isLoading = false - let responseData = response.data - - do { - let jsonDecoder = JSONDecoder() - let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) - - if let data = decoded.data, decoded.success { - if data.gender == 0 { - UserDefaults.set(ContentType.FEMALE.rawValue, forKey: .contentPreference) - } else { - UserDefaults.set(ContentType.MALE.rawValue, forKey: .contentPreference) - } - - if let onSuccess = onSuccess { - onSuccess() - } else { - AppState.shared.isRestartApp = true - AppState.shared.setAppStep(step: .splash) - } - } else { - if let message = decoded.message { - self.errorMessage = message - } else { - self.errorMessage = I18n.MyPage.Auth.verificationErrorWithSupport - } - self.isShowPopup = true - } - } catch { - self.errorMessage = I18n.MyPage.Auth.verificationErrorWithSupport - self.isShowPopup = true - } - } - .store(in: &subscription) - } else { - isLoading = false + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.authVerify(data, onSuccess: onSuccess) + } + return + } + guard pendingAuthReceiptId == nil, !isSubmittingAuth else { return } + + guard let payload = data["data"] as? [String: Any], + let receiptId = payload["receipt_id"] as? String, + !receiptId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { errorMessage = I18n.Main.Auth.authenticationError isShowPopup = true + return } + + pendingAuthReceiptId = receiptId + pendingAuthSuccess = onSuccess + authIsAdultContentVisible = true + authContentType = .ALL + } + + func presentAuthContentSettings() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.presentAuthContentSettings() + } + return + } + guard pendingAuthReceiptId != nil, !isSubmittingAuth else { return } + isShowAuthContentSettings = true + } + + func confirmAuthContentSettings() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.confirmAuthContentSettings() + } + return + } + guard let receiptId = pendingAuthReceiptId, !isSubmittingAuth else { return } + + pendingAuthRequest = AuthVerifyRequest( + receiptId: receiptId, + isAdultContentVisible: authIsAdultContentVisible, + contentType: authIsAdultContentVisible ? authContentType : .ALL + ) + isSubmittingAuth = true + isShowAuthContentSettings = false + } + + func submitPendingAuthVerification() { + guard Thread.isMainThread else { + DispatchQueue.main.async { [weak self] in + self?.submitPendingAuthVerification() + } + return + } + guard isSubmittingAuth, let request = pendingAuthRequest else { return } + pendingAuthRequest = nil + + authRepository.authCertificate(request: request) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + if case .failure = result { + self?.handleAuthSubmissionFailure() + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + + guard (200..<300).contains(response.statusCode) else { + self.handleAuthSubmissionFailure() + return + } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + guard decoded.success, decoded.data != nil else { + self.handleAuthSubmissionFailure() + return + } + + if self.pendingAuthSuccess != nil { + self.synchronizeAuthenticatedMember() + } else { + UserDefaults.set(request.isAdultContentVisible, forKey: .isAdultContentVisible) + UserDefaults.set(request.contentType.rawValue, forKey: .contentPreference) + self.restartAfterAuthVerification() + } + } catch { + self.handleAuthSubmissionFailure() + } + } + .store(in: &subscription) + } + + private func handleAuthSubmissionFailure() { + pendingAuthRequest = nil + isSubmittingAuth = false + errorMessage = I18n.MyPage.Auth.verificationErrorWithSupport + isShowPopup = true + isShowAuthContentSettings = true + } + + private func synchronizeAuthenticatedMember() { + repository.getMemberInfo() + .receive(on: DispatchQueue.main) + .sink { [weak self] result in + if case .failure = result { + self?.restartAfterAuthVerification() + } + } receiveValue: { [weak self] response in + guard let self = self else { return } + + guard (200..<300).contains(response.statusCode) else { + self.restartAfterAuthVerification() + return + } + + do { + let decoded = try JSONDecoder().decode(ApiResponse.self, from: response.data) + guard let data = decoded.data, decoded.success, data.isAuth else { + self.restartAfterAuthVerification() + return + } + + UserDefaults.set(data.can, forKey: .can) + UserDefaults.set(data.point, forKey: .point) + UserDefaults.set(data.isAuth, forKey: .auth) + UserDefaults.set(data.role.rawValue, forKey: .role) + UserDefaults.set(data.auditionNotice ?? false, forKey: .isAuditionNotification) + UserDefaults.set(data.countryCode, forKey: .countryCode) + UserDefaults.set(data.isAdultContentVisible, forKey: .isAdultContentVisible) + UserDefaults.set(data.isAdultContentVisible ? data.contentType : ContentType.ALL.rawValue, forKey: .contentPreference) + self.isAuth = data.isAuth + + let onSuccess = self.pendingAuthSuccess + self.clearPendingAuthVerification() + onSuccess?() + } catch { + self.restartAfterAuthVerification() + } + } + .store(in: &subscription) + } + + private func clearPendingAuthVerification() { + pendingAuthReceiptId = nil + pendingAuthRequest = nil + pendingAuthSuccess = nil + isSubmittingAuth = false + isShowAuthContentSettings = false + } + + private func restartAfterAuthVerification() { + clearPendingAuthVerification() + AppState.shared.isRestartApp = true + AppState.shared.setAppStep(step: .splash) } func getLatestNotice() { diff --git a/SodaLive/Sources/User/UserRepository.swift b/SodaLive/Sources/User/UserRepository.swift index 39e28abb..74cc60b9 100644 --- a/SodaLive/Sources/User/UserRepository.swift +++ b/SodaLive/Sources/User/UserRepository.swift @@ -46,19 +46,45 @@ final class UserRepository { } func getMypage() -> AnyPublisher { + #if AUTH_VERIFICATION_MOCK + return selfMemberPublisher(.getMypage, as: MyPageResponse.self) + #else return api.requestPublisher(.getMypage) + #endif } func getMemberInfo() -> AnyPublisher { + #if AUTH_VERIFICATION_MOCK + return selfMemberPublisher(.getMemberInfo, as: GetMemberInfoResponse.self) + #else return api.requestPublisher(.getMemberInfo) + #endif } func updateContentPreference(request: UpdateContentPreferenceRequest) -> AnyPublisher { + #if AUTH_VERIFICATION_MOCK + let requestToken = UserDefaults.string(forKey: .token) + let session = AuthVerificationMock.shared.session(for: requestToken) return api.requestPublisher(.updateContentPreference(request: request)) + .map { response in + AuthVerificationMock.shared.updateContentPreference(from: response.data, + statusCode: response.statusCode, + requestToken: requestToken, + session: session) + return response + } + .eraseToAnyPublisher() + #else + return api.requestPublisher(.updateContentPreference(request: request)) + #endif } func getMemberCan() -> AnyPublisher { + #if AUTH_VERIFICATION_MOCK + return getMemberInfo() + #else return api.requestPublisher(.getMemberInfo) + #endif } func updateNotificationSettings(live: Bool? = nil, uploadContent: Bool? = nil, message: Bool? = nil, audition: Bool? = nil) -> AnyPublisher { @@ -149,4 +175,23 @@ final class UserRepository { func getMemberProfile(memberId: Int) -> AnyPublisher { return api.requestPublisher(.getMemberProfile(memberId: memberId)) } + + #if AUTH_VERIFICATION_MOCK + private func selfMemberPublisher(_ target: UserApi, as type: Member.Type) -> AnyPublisher { + let requestToken = UserDefaults.string(forKey: .token) + let session = AuthVerificationMock.shared.session(for: requestToken) + return api.requestPublisher(target) + .map { response in + let data = AuthVerificationMock.shared.overlayMemberResponse(response.data, + statusCode: response.statusCode, + requestToken: requestToken, + session: session, + as: type) + guard data != response.data else { return response } + return Response(statusCode: response.statusCode, data: data, + request: response.request, response: response.response) + } + .eraseToAnyPublisher() + } + #endif } diff --git a/SodaLive/Sources/V2/Auth/AuthVerificationMock.swift b/SodaLive/Sources/V2/Auth/AuthVerificationMock.swift new file mode 100644 index 00000000..57362226 --- /dev/null +++ b/SodaLive/Sources/V2/Auth/AuthVerificationMock.swift @@ -0,0 +1,86 @@ +#if AUTH_VERIFICATION_MOCK +import Foundation + +final class AuthVerificationMock { + static let shared = AuthVerificationMock() + + final class Session { + fileprivate let token: String + fileprivate var preference: (isAdultContentVisible: Bool, contentType: ContentType)? + + fileprivate init(token: String) { + self.token = token + } + } + + private let lock = NSLock() + private var loginSession: Session? + + private init() {} + + func confirm(request: AuthVerifyRequest, requestToken: String) -> Data? { + lock.lock() + defer { lock.unlock() } + guard let session = currentSession(for: requestToken) else { return nil } + + session.preference = (request.isAdultContentVisible, request.isAdultContentVisible ? request.contentType : .ALL) + return Data(#"{"success":true,"data":{"gender":0}}"#.utf8) + } + + func session(for requestToken: String) -> Session? { + lock.lock() + defer { lock.unlock() } + return currentSession(for: requestToken) + } + + func resetSession(_ changes: () -> Void) { + lock.lock() + defer { lock.unlock() } + loginSession = nil + changes() + } + + func overlayMemberResponse(_ data: Data, statusCode: Int, requestToken: String, + session: Session?, as type: Member.Type) -> Data { + lock.lock() + defer { lock.unlock() } + guard isActive(session, requestToken: requestToken), let preference = session?.preference, + (200..<300).contains(statusCode), + let decoded = try? JSONDecoder().decode(ApiResponse.self, from: data), + decoded.success, decoded.data != nil, + var envelope = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + var member = envelope["data"] as? [String: Any] else { return data } + + member["isAuth"] = true + member["isAdultContentVisible"] = preference.isAdultContentVisible + member["contentType"] = preference.contentType.rawValue + envelope["data"] = member + return (try? JSONSerialization.data(withJSONObject: envelope)) ?? data + } + + func updateContentPreference(from data: Data, statusCode: Int, requestToken: String, session: Session?) { + lock.lock() + defer { lock.unlock() } + guard isActive(session, requestToken: requestToken), let session = session, session.preference != nil, + (200..<300).contains(statusCode), + let decoded = try? JSONDecoder().decode(ApiResponse.self, from: data), + decoded.success, let preference = decoded.data else { return } + + session.preference = (preference.isAdultContentVisible, preference.isAdultContentVisible ? preference.contentType : .ALL) + } + + private func currentSession(for requestToken: String) -> Session? { + guard !requestToken.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + requestToken == UserDefaults.string(forKey: .token) else { return nil } + if loginSession?.token != requestToken { + loginSession = Session(token: requestToken) + } + return loginSession + } + + private func isActive(_ session: Session?, requestToken: String) -> Bool { + guard let session = session, session === loginSession else { return false } + return session.token == requestToken && requestToken == UserDefaults.string(forKey: .token) + } +} +#endif diff --git a/SodaLive/Sources/V2/Component/Modal/AuthContentSettingsSheet.swift b/SodaLive/Sources/V2/Component/Modal/AuthContentSettingsSheet.swift new file mode 100644 index 00000000..1e5e3181 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Modal/AuthContentSettingsSheet.swift @@ -0,0 +1,349 @@ +import SwiftUI + +private enum AuthContentSettingsLayout { + static let topInset: CGFloat = 26 + static let headerHeight: CGFloat = 54 + static let touchHeight: CGFloat = 44 + static let buttonHeight: CGFloat = 52 + static let titleLineHeight: CGFloat = 23 + static let captionLineHeight: CGFloat = 17 + static let radioSize: CGFloat = 22 + static let radioDotSize: CGFloat = 12 + static let radioRowGap: CGFloat = 1 + static let radioBottomInset: CGFloat = 10 + static let switchWidth: CGFloat = 44 + static let switchHeight: CGFloat = 24 + static let switchInset: CGFloat = 2 + static let strokeWidth: CGFloat = 1 +} + +struct AuthContentSettingsSheet: View { + @Binding var isAdultContentVisible: Bool + @Binding var contentType: ContentType + let onConfirm: () -> Void + + var body: some View { + VStack(spacing: 0) { + ViewThatFits(in: .vertical) { + settingsContent + .fixedSize(horizontal: false, vertical: true) + + ScrollView(.vertical, showsIndicators: false) { + settingsContent + } + .scrollBounceBehavior(.basedOnSize) + } + + Button(action: onConfirm) { + Text(I18n.Settings.Content.confirmAuthSettings) + .appFont(size: SodaTypography.heading4.size, weight: .medium) + .foregroundColor(.white) + .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.buttonHeight) + .background(Color.soda400) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("authContentSettings.confirm") + .padding(.horizontal, SodaSpacing.s14) + .padding(.vertical, SodaSpacing.s8) + .fixedSize(horizontal: false, vertical: true) + } + .background(Color.gray900) + } + + private var settingsContent: some View { + VStack(spacing: 0) { + Text(I18n.Settings.contentViewSettings) + .appFont(size: SodaTypography.heading4.size, weight: .medium) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.headerHeight) + .accessibilityAddTraits(.isHeader) + + sensitiveContentRow + .padding(.top, SodaSpacing.s8) + .padding(.bottom, isAdultContentVisible ? SodaSpacing.s14 : SodaSpacing.s8) + + if isAdultContentVisible { + preferenceSection + } + + HStack(alignment: .top, spacing: SodaSpacing.s6) { + Image(systemName: "info.circle") + .resizable() + .frame(width: SodaSpacing.s16, height: SodaSpacing.s16) + .accessibilityHidden(true) + + Text(I18n.Settings.Content.authSettingsGuide) + .appFont(.caption3) + .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.captionLineHeight, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(.gray400) + .padding(.top, isAdultContentVisible ? 0 : SodaSpacing.s8) + .padding(.bottom, SodaSpacing.s20) + } + .padding(.top, AuthContentSettingsLayout.topInset) + .padding(.horizontal, SodaSpacing.s14) + } + + private var sensitiveContentRow: some View { + HStack(spacing: SodaSpacing.s12) { + VStack(alignment: .leading, spacing: SodaSpacing.s4) { + Text(I18n.Settings.Content.sensitiveContentTitle) + .appFont(.body2) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(minHeight: AuthContentSettingsLayout.titleLineHeight, alignment: .leading) + + Text(isAdultContentVisible ? I18n.Settings.Content.authSensitiveContentOnDescription : I18n.Settings.Content.authSensitiveContentOffDescription) + .appFont(.caption3) + .foregroundColor(.gray400) + .frame(minHeight: AuthContentSettingsLayout.captionLineHeight, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Toggle(I18n.Settings.Content.sensitiveContentTitle, isOn: $isAdultContentVisible) + .toggleStyle(AuthContentSettingsToggleStyle()) + .accessibilityIdentifier("authContentSettings.toggle") + } + } + + private var preferenceSection: some View { + VStack(alignment: .leading, spacing: 0) { + Rectangle() + .fill(Color.gray800) + .frame(height: AuthContentSettingsLayout.strokeWidth) + + Text(I18n.Settings.Content.preferredContent) + .appFont(.caption2) + .foregroundColor(.gray400) + .frame(minHeight: AuthContentSettingsLayout.captionLineHeight) + .padding(.top, SodaSpacing.s14) + + HStack(spacing: SodaSpacing.s12) { + preferenceButton(.ALL, title: I18n.Settings.Content.all) + preferenceButton(.MALE, title: I18n.Settings.Content.maleOriented) + preferenceButton(.FEMALE, title: I18n.Settings.Content.femaleOriented) + } + .padding(.top, AuthContentSettingsLayout.radioRowGap) + .padding(.bottom, AuthContentSettingsLayout.radioBottomInset) + } + } + + private func preferenceButton(_ type: ContentType, title: String) -> some View { + Button { + contentType = type + } label: { + HStack(spacing: SodaSpacing.s6) { + Circle() + .stroke(Color.gray700, lineWidth: AuthContentSettingsLayout.strokeWidth) + .frame(width: AuthContentSettingsLayout.radioSize, height: AuthContentSettingsLayout.radioSize) + .overlay { + if contentType == type { + Circle() + .fill(Color.soda400) + .frame(width: AuthContentSettingsLayout.radioDotSize, height: AuthContentSettingsLayout.radioDotSize) + } + } + + Text(title) + .appFont(.body2) + .foregroundColor(.white) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.touchHeight, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + .accessibilityValue(contentType == type ? I18n.Settings.Content.selectedPreference : I18n.Settings.Content.unselectedPreference) + .accessibilityAddTraits(contentType == type ? .isSelected : []) + .accessibilityIdentifier("authContentSettings.preference.\(type.rawValue)") + } +} + +private struct AuthContentSettingsToggleStyle: ToggleStyle { + func makeBody(configuration: Configuration) -> some View { + Button { + configuration.isOn.toggle() + } label: { + Capsule() + .fill(configuration.isOn ? Color.soda400 : Color.gray500) + .frame(width: AuthContentSettingsLayout.switchWidth, height: AuthContentSettingsLayout.switchHeight) + .overlay(alignment: configuration.isOn ? .trailing : .leading) { + Circle() + .fill(Color.white) + .frame(width: SodaSpacing.s20, height: SodaSpacing.s20) + .padding(AuthContentSettingsLayout.switchInset) + } + .frame(minHeight: AuthContentSettingsLayout.touchHeight) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityRepresentation { + Toggle(isOn: configuration.$isOn) { + configuration.label + } + .toggleStyle(.switch) + } + } +} + +private struct AuthContentSettingsModifier: ViewModifier { + @ObservedObject var viewModel: MyPageViewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isHostPresented = false + @State private var panelProgress: CGFloat = 0 + + private var callerErrorIsPresented: Binding { + Binding( + get: { viewModel.isShowPopup && !viewModel.isShowAuthContentSettings }, + set: { viewModel.isShowPopup = $0 } + ) + } + + func body(content: Content) -> some View { + content + .disabled(viewModel.isSubmittingAuth) + .overlay { + if viewModel.isSubmittingAuth { + LoadingView() + } + } + .sodaToast(isPresented: callerErrorIsPresented, message: viewModel.errorMessage, autohideIn: 2) + .fullScreenCover(isPresented: $isHostPresented, onDismiss: { + panelProgress = 0 + viewModel.submitPendingAuthVerification() + }) { + GeometryReader { proxy in + ZStack(alignment: .bottom) { + Color.black.opacity(0.6) + .ignoresSafeArea() + + AuthContentSettingsSheet( + isAdultContentVisible: $viewModel.authIsAdultContentVisible, + contentType: $viewModel.authContentType, + onConfirm: viewModel.confirmAuthContentSettings + ) + .padding(.leading, proxy.safeAreaInsets.leading) + .padding(.trailing, proxy.safeAreaInsets.trailing) + .padding(.bottom, proxy.safeAreaInsets.bottom) + .background(Color.gray900) + .clipShape(RoundedCorner(radius: SodaRadius.r14, corners: [.topLeft, .topRight])) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("authContentSettings.panel") + .disabled(viewModel.isSubmittingAuth) + .overlay { + if viewModel.isSubmittingAuth { + LoadingView() + } + } + .sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2) + .modifier(AuthContentSettingsPanelMotion( + progress: panelProgress, + reduceMotion: reduceMotion, + onHidden: closeHostIfHidden + )) + } + .ignoresSafeArea(.container, edges: [.horizontal, .bottom]) + } + .interactiveDismissDisabled(true) + .presentationBackground(.clear) + .onAppear { + DispatchQueue.main.async { + updatePanel() + } + } + } + .onAppear { + if viewModel.isShowAuthContentSettings { + setHostPresented(true) + } + } + .onChange(of: viewModel.isShowAuthContentSettings) { isPresented in + if isPresented && !isHostPresented { + setHostPresented(true) + } else if isHostPresented { + updatePanel() + } + } + .onChange(of: reduceMotion) { _ in + if isHostPresented { + updatePanel() + } + } + } + + private func setHostPresented(_ isPresented: Bool) { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isHostPresented = isPresented + } + } + + private func updatePanel() { + let progress: CGFloat = viewModel.isShowAuthContentSettings ? 1 : 0 + if reduceMotion || panelProgress == progress { + panelProgress = progress + closeHostIfHidden() + } else { + withAnimation(.easeInOut(duration: 0.25)) { + panelProgress = progress + } + } + } + + private func closeHostIfHidden() { + guard isHostPresented, panelProgress == 0, !viewModel.isShowAuthContentSettings else { return } + setHostPresented(false) + } +} + +private struct AuthContentSettingsPanelMotion: GeometryEffect { + var progress: CGFloat + let reduceMotion: Bool + let onHidden: () -> Void + + var animatableData: CGFloat { + get { progress } + set { + progress = newValue + } + } + + func effectValue(size: CGSize) -> ProjectionTransform { + if progress == 0 { + DispatchQueue.main.async(execute: onHidden) + } + return ProjectionTransform(CGAffineTransform(translationX: 0, y: reduceMotion ? 0 : size.height * (1 - progress))) + } +} + +extension View { + func authContentSettings(viewModel: MyPageViewModel) -> some View { + modifier(AuthContentSettingsModifier(viewModel: viewModel)) + } +} + +struct AuthContentSettingsSheet_Previews: PreviewProvider { + static var previews: some View { + Group { + AuthContentSettingsSheet(isAdultContentVisible: .constant(true), contentType: .constant(.ALL), onConfirm: {}) + .frame(width: 402) + .previewDisplayName("ON / ALL") + + AuthContentSettingsSheet(isAdultContentVisible: .constant(false), contentType: .constant(.ALL), onConfirm: {}) + .frame(width: 402) + .previewDisplayName("OFF / ALL") + } + .padding(.bottom, 34) + .background(Color.gray900) + .clipShape(RoundedCorner(radius: SodaRadius.r14, corners: [.topLeft, .topRight])) + .environment(\.locale, Locale(identifier: "ko")) + .preferredColorScheme(.dark) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/Component/Modal/AuthVerificationView.swift b/SodaLive/Sources/V2/Component/Modal/AuthVerificationView.swift new file mode 100644 index 00000000..f416d195 --- /dev/null +++ b/SodaLive/Sources/V2/Component/Modal/AuthVerificationView.swift @@ -0,0 +1,46 @@ +import SwiftUI + +import Bootpay +#if !AUTH_VERIFICATION_MOCK +import BootpayUI +#endif + +struct AuthVerificationView: View { + let payload: Payload + let onCancel: () -> Void + let onError: () -> Void + let onDone: ([String: Any]) -> Void + let onClose: () -> Void + + #if AUTH_VERIFICATION_MOCK + @State private var hasCompleted = false + #endif + + var body: some View { + #if AUTH_VERIFICATION_MOCK + LoadingView() + .background(Color.white.ignoresSafeArea()) + .task { + guard !hasCompleted else { return } + do { + try await Task.sleep(nanoseconds: 500_000_000) + } catch is CancellationError { + return + } catch { + onError() + return + } + guard !Task.isCancelled, !hasCompleted else { return } + hasCompleted = true + onDone(["data": ["receipt_id": "dev-mock-auth-receipt"]]) + } + #else + BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) + .onConfirm { _ in true } + .onCancel { _ in onCancel() } + .onError { _ in onError() } + .onDone(onDone) + .onClose(onClose) + #endif + } +} diff --git a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift index 3290210c..46bd7323 100644 --- a/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/CreatorChannelView.swift @@ -1,6 +1,5 @@ import SwiftUI import Bootpay -import BootpayUI struct CreatorChannelView: View { let creatorId: Int @@ -256,9 +255,10 @@ struct CreatorChannelView: View { ActivityViewController(activityItems: [viewModel.shareMessage]) } ) - .fullScreenCover(isPresented: $isShowAuthView) { + .fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) { authView } + .authContentSettings(viewModel: mypageViewModel) .onAppear { configurePayload() if viewModel.hasLoaded == false { @@ -490,26 +490,25 @@ struct CreatorChannelView: View { } private var authView: some View { - BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) - .onConfirm { _ in true } - .onCancel { _ in isShowAuthView = false } - .onError { _ in + AuthVerificationView( + payload: payload, + onCancel: { isShowAuthView = false }, + onError: { AppState.shared.errorMessage = I18n.Chat.Auth.authenticationError AppState.shared.isShowErrorPopup = true isShowAuthView = false - } - .onDone { - DEBUG_LOG("onDone: \($0)") + }, + onDone: { + let action = pendingAction mypageViewModel.authVerify($0) { auth = true - isShowAuthView = false - if let action = pendingAction { - pendingAction = nil - action() - } + pendingAction = nil + action?() } - } - .onClose { isShowAuthView = false } + isShowAuthView = false + }, + onClose: { isShowAuthView = false } + ) } private func configurePayload() { diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 467e4f8d..ef505510 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -6,7 +6,6 @@ import SwiftUI import Bootpay -import BootpayUI import Kingfisher struct MainView: View { @@ -148,7 +147,7 @@ struct MainView: View { ContentPlayerView(isShowing: $isShowPlayer, playlist: []) } } - .fullScreenCover(isPresented: $isShowAuthView) { + .fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) { authView } .valueChanged(value: appState.pushRoomId) { handlePushRoomId($0) } @@ -175,6 +174,7 @@ struct MainView: View { autohideIn: 2 ) } + .authContentSettings(viewModel: mypageViewModel) } private var shouldShowCreatorActionMenu: Bool { @@ -383,26 +383,25 @@ struct MainView: View { } private var authView: some View { - BootpayUI(payload: payload, requestType: BootpayRequest.TYPE_AUTHENTICATION) - .onConfirm { _ in true } - .onCancel { _ in isShowAuthView = false } - .onError { _ in + AuthVerificationView( + payload: payload, + onCancel: { isShowAuthView = false }, + onError: { appState.errorMessage = I18n.Main.Auth.authenticationError appState.isShowErrorPopup = true isShowAuthView = false - } - .onDone { - DEBUG_LOG("onDone: \($0)") + }, + onDone: { + let action = pendingAction mypageViewModel.authVerify($0) { auth = true - isShowAuthView = false - if let action = pendingAction { - pendingAction = nil - action() - } + pendingAction = nil + action?() } - } - .onClose { isShowAuthView = false } + isShowAuthView = false + }, + onClose: { isShowAuthView = false } + ) } private func configurePayload() { diff --git a/work/scripts/check-auth-content-settings.swift b/work/scripts/check-auth-content-settings.swift new file mode 100644 index 00000000..b76c19fc --- /dev/null +++ b/work/scripts/check-auth-content-settings.swift @@ -0,0 +1,39 @@ +import Foundation +import CoreFoundation + +@main +struct AuthContentSettingsCheck { + static func main() throws { + let receiptId = "synthetic-auth-receipt" + let cases: [(Bool, ContentType)] = [ + (true, .ALL), + (true, .MALE), + (true, .FEMALE), + (false, .ALL) + ] + + for (isAdultContentVisible, contentType) in cases { + let request = AuthVerifyRequest( + receiptId: receiptId, + isAdultContentVisible: isAdultContentVisible, + contentType: contentType + ) + let encoded = try JSONEncoder().encode(request) + guard let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any], + let encodedReceipt = json["receipt_id"] as? String, + let encodedVisibility = json["isAdultContentVisible"] as? NSNumber, + let encodedContentType = json["contentType"] as? String else { + assertionFailure("Expected receipt string, visibility bool and content type string") + return + } + + assert(Set(json.keys) == Set(["receipt_id", "isAdultContentVisible", "contentType"])) + assert(encodedReceipt == receiptId) + assert(CFGetTypeID(encodedVisibility) == CFBooleanGetTypeID()) + assert(encodedVisibility.boolValue == isAdultContentVisible) + assert(encodedContentType == contentType.rawValue) + } + + print("PASS: AuthVerifyRequest exact JSON keys/types and ON ALL/MALE/FEMALE, OFF ALL (4 cases)") + } +} diff --git a/work/tests/AuthVerificationMockCheck.swift b/work/tests/AuthVerificationMockCheck.swift new file mode 100644 index 00000000..e5eb541d --- /dev/null +++ b/work/tests/AuthVerificationMockCheck.swift @@ -0,0 +1,229 @@ +import Foundation +import CoreFoundation + +@main +struct AuthVerificationMockCheck { + static let mock = AuthVerificationMock.shared + static let token = "synthetic-session-a" + static let otherToken = "synthetic-session-b" + static let member = Data(#"{"success":true,"message":null,"extra":{"trace":17},"data":{"can":137,"point":29,"isAuth":false,"gender":"FEMALE","signupDate":"synthetic-date","chargeCount":3,"role":"CREATOR","messageNotice":true,"followingChannelLiveNotice":false,"followingChannelUploadContentNotice":true,"auditionNotice":false,"countryCode":" kr ","isAdultContentVisible":false,"contentType":"MALE","extra":{"nested":[1,true,null,"keep"]}}}"#.utf8) + static let mypage = Data(#"{"success":true,"data":{"nickname":"synthetic-name","profileUrl":"synthetic-profile","chargeCan":71,"rewardCan":42,"point":19,"youtubeUrl":null,"instagramUrl":"keep","liveReservationCount":4,"isAuth":false,"extra":{"untouched":true}}}"#.utf8) + + static func request(visible: Bool = true, type: ContentType = .FEMALE) -> AuthVerifyRequest { + AuthVerifyRequest(receiptId: "synthetic-check-receipt", isAdultContentVisible: visible, contentType: type) + } + + static func object(_ data: Data) -> NSDictionary { + try! JSONSerialization.jsonObject(with: data) as! NSDictionary + } + + static func overlay(_ data: Data = member, status: Int = 200, requestToken: String = token) -> Data { + mock.overlayMemberResponse(data, statusCode: status, requestToken: requestToken, + session: mock.session(for: requestToken), as: GetMemberInfoResponse.self) + } + + static func begin() { + UserDefaults.reset() + UserDefaults.set(token, forKey: .token) + } + + static func confirm(visible: Bool = true, type: ContentType = .FEMALE) { + assert(mock.confirm(request: request(visible: visible, type: type), requestToken: token) != nil) + } + + static func checkLatePreConfirmationResponse() { + begin() + let beforeConfirmation = mock.session(for: token) + assert(mock.overlayMemberResponse(member, statusCode: 200, requestToken: token, + session: beforeConfirmation, as: GetMemberInfoResponse.self) == member) + confirm() + let delayed = mock.overlayMemberResponse(member, statusCode: 200, requestToken: token, + session: beforeConfirmation, as: GetMemberInfoResponse.self) + let decoded = try! JSONDecoder().decode(ApiResponse.self, from: delayed).data! + assert(decoded.isAuth && decoded.isAdultContentVisible && decoded.contentType == "FEMALE", + "Late pre-confirmation GET must use true/FEMALE, got \(decoded.isAuth)/\(decoded.contentType)") + assert(decoded.can == 137 && decoded.point == 29 && decoded.role == .CREATOR) + confirm(visible: false, type: .MALE) + let updated = mock.overlayMemberResponse(member, statusCode: 200, requestToken: token, + session: beforeConfirmation, as: GetMemberInfoResponse.self) + let latest = try! JSONDecoder().decode(ApiResponse.self, from: updated).data! + assert(latest.isAuth && !latest.isAdultContentVisible && latest.contentType == "ALL", + "Confirmation must not replace the login epoch; delayed GET uses latest confirmed choice") + print("PASS: late pre-confirmation GET uses current confirmed choice within the same login session") + } + + static func checkConfirmationAndThreeFieldOverlay() { + begin() + UserDefaults.set(false, forKey: .auth) + UserDefaults.set(false, forKey: .isAdultContentVisible) + UserDefaults.set("MALE", forKey: .contentPreference) + assert(overlay() == member, "No override before confirmation") + let response = mock.confirm(request: request(), requestToken: token)! + let auth = try! JSONDecoder().decode(ApiResponse.self, from: response) + assert(auth.success && auth.data?.gender == 0) + assert(!UserDefaults.bool(forKey: .auth)) + assert(!UserDefaults.bool(forKey: .isAdultContentVisible)) + assert(UserDefaults.string(forKey: .contentPreference) == "MALE") + + let result = overlay() + let envelope = object(result) + let data = envelope["data"] as! NSDictionary + assert(data["isAuth"] as! Bool && data["isAdultContentVisible"] as! Bool) + assert(data["contentType"] as! String == "FEMALE") + assert(CFGetTypeID(data["isAuth"] as! NSNumber) == CFBooleanGetTypeID()) + assert(CFGetTypeID(data["isAdultContentVisible"] as! NSNumber) == CFBooleanGetTypeID()) + let unchanged = data.mutableCopy() as! NSMutableDictionary + unchanged["isAuth"] = false + unchanged["isAdultContentVisible"] = false + unchanged["contentType"] = "MALE" + let restored = envelope.mutableCopy() as! NSMutableDictionary + restored["data"] = unchanged + assert(restored == object(member), "Only three fields may change, including unknown fields") + let decoded = try! JSONDecoder().decode(ApiResponse.self, from: result).data! + assert(decoded.can == 137 && decoded.point == 29 && decoded.role == .CREATOR) + assert(decoded.gender == "FEMALE" && decoded.countryCode == "KR" && decoded.auditionNotice == false) + + let mypageResult = mock.overlayMemberResponse(mypage, statusCode: 200, requestToken: token, + session: mock.session(for: token), as: MyPageResponse.self) + let page = try! JSONDecoder().decode(ApiResponse.self, from: mypageResult).data! + assert(page.isAuth && page.chargeCan == 71 && page.rewardCan == 42 && page.point == 19) + let originalPage = (object(mypage)["data"] as! NSDictionary).mutableCopy() as! NSMutableDictionary + originalPage["isAuth"] = true + originalPage["isAdultContentVisible"] = true + originalPage["contentType"] = "FEMALE" + assert(originalPage == object(mypageResult)["data"] as! NSDictionary) + + for (visible, type, expected) in [(true, ContentType.ALL, "ALL"), (true, .MALE, "MALE"), + (true, .FEMALE, "FEMALE"), (false, .FEMALE, "ALL")] { + confirm(visible: visible, type: type) + let data = object(overlay())["data"] as! NSDictionary + assert(data["isAdultContentVisible"] as! Bool == visible) + assert(data["contentType"] as! String == expected) + } + print("PASS: confirmation-only state, existing auth DTO, three-field self-member/mypage overlay, enum and OFF normalization") + } + + static func checkInvalidResponses() { + begin() + confirm() + let invalid = ["not-json", "[]", #"{"success":false,"data":null,"message":"keep-error"}"#, + #"{"success":true,"data":null}"#, #"{"success":true,"data":{}}"#, + #"{"success":"true","data":{}}"#] + for json in invalid { + let data = Data(json.utf8) + assert(overlay(data) == data, "Invalid envelope must stay byte-identical") + assert(mock.overlayMemberResponse(data, statusCode: 200, requestToken: token, + session: mock.session(for: token), as: MyPageResponse.self) == data) + } + let unsuccessful = object(member).mutableCopy() as! NSMutableDictionary + unsuccessful["success"] = false + let unsuccessfulData = try! JSONSerialization.data(withJSONObject: unsuccessful) + assert(overlay(unsuccessfulData) == unsuccessfulData) + for status in [199, 300, 401, 403, 500] { + assert(overlay(status: status) == member, "HTTP failure must not be repaired") + } + for (key, value) in [("isAuth", "false" as Any), ("can", "137" as Any), + ("role", "UNKNOWN" as Any), ("isAdultContentVisible", "false" as Any), + ("contentType", 7 as Any)] { + let envelope = object(member).mutableCopy() as! NSMutableDictionary + let data = (envelope["data"] as! NSDictionary).mutableCopy() as! NSMutableDictionary + data[key] = value + envelope["data"] = data + let malformed = try! JSONSerialization.data(withJSONObject: envelope) + assert(overlay(malformed) == malformed, "DTO type failures must not be repaired") + } + print("PASS: malformed, non-success, null/missing data, DTO type and non-2xx failures are byte-identical") + } + + static func checkSessionLifetimeAndIsolation() { + UserDefaults.reset() + for blank in ["", " \n "] { + UserDefaults.set(blank, forKey: .token) + assert(mock.confirm(request: request(), requestToken: blank) == nil) + assert(overlay(requestToken: blank) == member) + } + begin() + assert(mock.confirm(request: request(), requestToken: otherToken) == nil) + confirm() + let oldSession = mock.session(for: token) + assert(overlay(requestToken: otherToken) == member) + assert(mock.overlayMemberResponse(member, statusCode: 200, requestToken: token, + session: nil, as: GetMemberInfoResponse.self) == member) + UserDefaults.set("synthetic-other-name", forKey: .nickname) + assert((object(overlay())["data"] as! NSDictionary)["isAuth"] as! Bool) + assert(AuthVerificationMock.shared === mock, "Process-wide state survives owner reconstruction/Splash") + UserDefaults.reset() + assert(overlay() == member) + UserDefaults.set(token, forKey: .token) + assert(overlay() == member, "Reset must not resurrect old state") + confirm() + UserDefaults.set(token, forKey: .token) + assert(overlay() == member, "Same-token relogin must reset state") + confirm() + assert(mock.overlayMemberResponse(member, statusCode: 200, requestToken: token, + session: oldSession, as: GetMemberInfoResponse.self) == member) + let activeSession = mock.session(for: token) + UserDefaults.set(otherToken, forKey: .token) + assert(overlay(requestToken: otherToken) == member) + assert(mock.confirm(request: request(type: .MALE), requestToken: otherToken) != nil) + assert(mock.overlayMemberResponse(member, statusCode: 200, requestToken: token, + session: activeSession, as: GetMemberInfoResponse.self) == member) + assert(mock.confirm(request: request(), requestToken: token) == nil) + assert((object(overlay(requestToken: otherToken))["data"] as! NSDictionary)["contentType"] as! String == "MALE") + print("PASS: blank/mismatch/stale tokens, reset/replacement/same-token relogin and in-flight account isolation") + } + + static func checkLaterPreferences() { + begin() + let update = Data(#"{"success":true,"data":{"isAdultContentVisible":true,"contentType":" male "}}"#.utf8) + mock.updateContentPreference(from: update, statusCode: 200, requestToken: token, session: nil) + mock.updateContentPreference(from: update, statusCode: 200, requestToken: token, session: mock.session(for: token)) + assert(overlay() == member, "Real preference success alone must not activate mock auth") + confirm() + let session = mock.session(for: token) + mock.updateContentPreference(from: update, statusCode: 200, requestToken: token, session: session) + assert((object(overlay())["data"] as! NSDictionary)["contentType"] as! String == "MALE") + let failures = [("bad-json", 200), (#"{"success":false,"data":{"isAdultContentVisible":false,"contentType":"ALL"}}"#, 200), + (#"{"success":true,"data":null}"#, 200), + (#"{"success":true,"data":{"isAdultContentVisible":"false","contentType":"ALL"}}"#, 200), + (#"{"success":true,"data":{"isAdultContentVisible":false,"contentType":"ALL"}}"#, 401)] + for (json, status) in failures { + mock.updateContentPreference(from: Data(json.utf8), statusCode: status, requestToken: token, session: session) + assert((object(overlay())["data"] as! NSDictionary)["contentType"] as! String == "MALE") + } + let off = Data(#"{"success":true,"data":{"isAdultContentVisible":false,"contentType":"FEMALE"}}"#.utf8) + mock.updateContentPreference(from: off, statusCode: 200, requestToken: token, session: session) + let data = object(overlay())["data"] as! NSDictionary + assert(!(data["isAdultContentVisible"] as! Bool) && data["contentType"] as! String == "ALL") + UserDefaults.set(token, forKey: .token) + confirm() + mock.updateContentPreference(from: update, statusCode: 200, requestToken: token, session: session) + assert((object(overlay())["data"] as! NSDictionary)["contentType"] as! String == "FEMALE") + print("PASS: real later preference success updates active snapshot; failure/inactive/stale requests do not") + } + + static func checkConcurrentCallbacks() { + begin() + confirm() + DispatchQueue.concurrentPerform(iterations: 100) { _ in + let session = mock.session(for: token) + let data = mock.overlayMemberResponse(member, statusCode: 200, requestToken: token, + session: session, as: GetMemberInfoResponse.self) + assert((object(data)["data"] as! NSDictionary)["isAuth"] as! Bool) + let update = Data(#"{"success":true,"data":{"isAdultContentVisible":true,"contentType":"FEMALE"}}"#.utf8) + mock.updateContentPreference(from: update, statusCode: 200, requestToken: token, session: session) + } + print("PASS: concurrent member/preference callbacks") + } + + static func main() { + defer { UserDefaults.reset() } + checkLatePreConfirmationResponse() + checkConfirmationAndThreeFieldOverlay() + checkInvalidResponses() + checkSessionLifetimeAndIsolation() + checkLaterPreferences() + checkConcurrentCallbacks() + print("PASS: actual Foundation-only AuthVerificationMock/DTO/UserDefaults sources") + } +} diff --git a/work/tests/check-auth-verification-mock.sh b/work/tests/check-auth-verification-mock.sh new file mode 100644 index 00000000..dca60742 --- /dev/null +++ b/work/tests/check-auth-verification-mock.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TEMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TEMP_DIR"' EXIT + +swiftc -swift-version 5 -D AUTH_VERIFICATION_MOCK -module-cache-path "$TEMP_DIR/module-cache" \ + "$ROOT/SodaLive/Sources/V2/Auth/AuthVerificationMock.swift" \ + "$ROOT/SodaLive/Sources/Extensions/UserDefaultsExtension.swift" \ + "$ROOT/SodaLive/Sources/Common/ApiResponse.swift" \ + "$ROOT/SodaLive/Sources/MyPage/Auth/AuthVerifyRequest.swift" \ + "$ROOT/SodaLive/Sources/MyPage/Auth/AuthResponse.swift" \ + "$ROOT/SodaLive/Sources/MyPage/MyPageResponse.swift" \ + "$ROOT/SodaLive/Sources/Settings/Content/ContentType.swift" \ + "$ROOT/SodaLive/Sources/Settings/Content/UpdateContentPreferenceResponse.swift" \ + "$ROOT/SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift" \ + "$ROOT/work/tests/AuthVerificationMockCheck.swift" \ + -o "$TEMP_DIR/check-auth-verification-mock" +"$TEMP_DIR/check-auth-verification-mock" + +for configuration in Debug Release; do + FLAGS=() + if [[ "$configuration" == Debug ]]; then FLAGS=(-D DEBUG); fi + swiftc -swift-version 5 -module-cache-path "$TEMP_DIR/module-cache" "${FLAGS[@]}" \ + -emit-library -module-name "AuthMockStandard$configuration" \ + "$ROOT/SodaLive/Sources/V2/Auth/AuthVerificationMock.swift" \ + "$ROOT/SodaLive/Sources/Extensions/UserDefaultsExtension.swift" \ + -o "$TEMP_DIR/standard-$configuration.dylib" + nm -j "$TEMP_DIR/standard-$configuration.dylib" > "$TEMP_DIR/symbols-$configuration" + if rg -q 'AuthVerificationMock' "$TEMP_DIR/symbols-$configuration"; then + printf 'FAIL: mock symbols in standard %s\n' "$configuration" >&2 + exit 1 + fi + printf 'PASS: standard %s contains no mock symbols\n' "$configuration" +done