feat(auth): 콘텐츠 설정에서 본인인증 종료를 지원한다
This commit is contained in:
@@ -202,7 +202,7 @@ struct ChatTabView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.authContentSettings(viewModel: mypageViewModel)
|
.authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ struct HomeTabView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||||
.authContentSettings(viewModel: mypageViewModel)
|
.authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1639,20 +1639,40 @@ enum I18n {
|
|||||||
enum Content {
|
enum Content {
|
||||||
static var authSensitiveContentOnDescription: String {
|
static var authSensitiveContentOnDescription: String {
|
||||||
pick(
|
pick(
|
||||||
ko: "성인인증 완료 후 성인 콘텐츠를 볼 수 있어요",
|
ko: "본인인증 완료 후 민감한 콘텐츠를 볼 수 있어요",
|
||||||
en: "You can view adult content after age verification.",
|
en: "You can view sensitive content after identity verification.",
|
||||||
ja: "年齢認証完了後、成人向けコンテンツを表示できます"
|
ja: "本人認証完了後、センシティブなコンテンツを表示できます"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var authSensitiveContentOffDescription: String {
|
static var authSensitiveContentOffDescription: String {
|
||||||
pick(
|
pick(
|
||||||
ko: "성인 콘텐츠가 목록과 검색에 노출되지 않아요",
|
ko: "민감한 콘텐츠가 목록과 검색에 노출되지 않아요",
|
||||||
en: "Adult content will not appear in lists or search.",
|
en: "Sensitive content will not appear in lists or search.",
|
||||||
ja: "成人向けコンテンツは一覧や検索に表示されません"
|
ja: "センシティブなコンテンツは一覧や検索に表示されません"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static var authExitTitle: String {
|
||||||
|
pick(ko: "본인인증 종료", en: "End identity verification", ja: "本人認証の終了")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var authExitMessage: String {
|
||||||
|
pick(
|
||||||
|
ko: "본인인증을 종료하시겠어요?\n종료하면 다시 본인인증을 진행해야 해요.",
|
||||||
|
en: "Do you want to end identity verification?\nIf you end it, you’ll need to verify your identity again.",
|
||||||
|
ja: "本人認証を終了しますか?\n終了すると、もう一度本人認証を行う必要があります。"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var authExitConfirm: String {
|
||||||
|
pick(ko: "종료하기", en: "End verification", ja: "終了する")
|
||||||
|
}
|
||||||
|
|
||||||
|
static var authExitCancel: String {
|
||||||
|
pick(ko: "계속하기", en: "Continue", ja: "続ける")
|
||||||
|
}
|
||||||
|
|
||||||
static var preferredContent: String {
|
static var preferredContent: String {
|
||||||
pick(ko: "선호 콘텐츠", en: "Preferred content", ja: "好みのコンテンツ")
|
pick(ko: "선호 콘텐츠", en: "Preferred content", ja: "好みのコンテンツ")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ struct LiveNowAllView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||||
.authContentSettings(viewModel: mypageViewModel)
|
.authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil })
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
|
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
|
||||||
|
|||||||
@@ -459,7 +459,7 @@ struct HomeView: View {
|
|||||||
autohideIn: 2
|
autohideIn: 2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.authContentSettings(viewModel: mypageViewModel)
|
.authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil })
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
|
private func handleLiveNowItemTap(roomId: Int, isAdult: Bool) {
|
||||||
|
|||||||
@@ -119,6 +119,19 @@ final class MyPageViewModel: ObservableObject {
|
|||||||
isShowAuthContentSettings = true
|
isShowAuthContentSettings = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cancelAuthContentSettings() {
|
||||||
|
guard Thread.isMainThread else {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.cancelAuthContentSettings()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isSubmittingAuth, isShowAuthContentSettings else { return }
|
||||||
|
|
||||||
|
clearPendingAuthVerification()
|
||||||
|
isShowPopup = false
|
||||||
|
}
|
||||||
|
|
||||||
func confirmAuthContentSettings() {
|
func confirmAuthContentSettings() {
|
||||||
guard Thread.isMainThread else {
|
guard Thread.isMainThread else {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ struct AuthContentSettingsSheet: View {
|
|||||||
@Binding var isAdultContentVisible: Bool
|
@Binding var isAdultContentVisible: Bool
|
||||||
@Binding var contentType: ContentType
|
@Binding var contentType: ContentType
|
||||||
let onConfirm: () -> Void
|
let onConfirm: () -> Void
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
|
||||||
ViewThatFits(in: .vertical) {
|
ViewThatFits(in: .vertical) {
|
||||||
settingsContent
|
settingsContent
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -49,17 +52,42 @@ struct AuthContentSettingsSheet: View {
|
|||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
.background(Color.gray900)
|
.background(Color.gray900)
|
||||||
|
.accessibilityAction(.escape, onClose)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var settingsContent: some View {
|
private var header: some View {
|
||||||
VStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: AuthContentSettingsLayout.touchHeight, height: AuthContentSettingsLayout.touchHeight)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
|
||||||
Text(I18n.Settings.contentViewSettings)
|
Text(I18n.Settings.contentViewSettings)
|
||||||
.appFont(size: SodaTypography.heading4.size, weight: .medium)
|
.appFont(size: SodaTypography.heading4.size, weight: .medium)
|
||||||
.foregroundColor(.white)
|
.foregroundColor(.white)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: .infinity, minHeight: AuthContentSettingsLayout.headerHeight)
|
.frame(maxWidth: .infinity)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
.accessibilityAddTraits(.isHeader)
|
.accessibilityAddTraits(.isHeader)
|
||||||
|
|
||||||
|
Button(action: onClose) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: SodaTypography.body2.size, weight: .medium))
|
||||||
|
.foregroundColor(.gray400)
|
||||||
|
.frame(width: AuthContentSettingsLayout.touchHeight, height: AuthContentSettingsLayout.touchHeight)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.accessibilityLabel(I18n.Settings.Content.authExitTitle)
|
||||||
|
.accessibilityIdentifier("authContentSettings.close")
|
||||||
|
}
|
||||||
|
.frame(minHeight: AuthContentSettingsLayout.headerHeight)
|
||||||
|
.padding(.top, AuthContentSettingsLayout.topInset)
|
||||||
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var settingsContent: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
sensitiveContentRow
|
sensitiveContentRow
|
||||||
.padding(.top, SodaSpacing.s8)
|
.padding(.top, SodaSpacing.s8)
|
||||||
.padding(.bottom, isAdultContentVisible ? SodaSpacing.s14 : SodaSpacing.s8)
|
.padding(.bottom, isAdultContentVisible ? SodaSpacing.s14 : SodaSpacing.s8)
|
||||||
@@ -83,7 +111,6 @@ struct AuthContentSettingsSheet: View {
|
|||||||
.padding(.top, isAdultContentVisible ? 0 : SodaSpacing.s8)
|
.padding(.top, isAdultContentVisible ? 0 : SodaSpacing.s8)
|
||||||
.padding(.bottom, SodaSpacing.s20)
|
.padding(.bottom, SodaSpacing.s20)
|
||||||
}
|
}
|
||||||
.padding(.top, AuthContentSettingsLayout.topInset)
|
|
||||||
.padding(.horizontal, SodaSpacing.s14)
|
.padding(.horizontal, SodaSpacing.s14)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,9 +220,12 @@ private struct AuthContentSettingsToggleStyle: ToggleStyle {
|
|||||||
|
|
||||||
private struct AuthContentSettingsModifier: ViewModifier {
|
private struct AuthContentSettingsModifier: ViewModifier {
|
||||||
@ObservedObject var viewModel: MyPageViewModel
|
@ObservedObject var viewModel: MyPageViewModel
|
||||||
|
let onCancel: () -> Void
|
||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@State private var isHostPresented = false
|
@State private var isHostPresented = false
|
||||||
@State private var panelProgress: CGFloat = 0
|
@State private var panelProgress: CGFloat = 0
|
||||||
|
@State private var isExitDialogPresented = false
|
||||||
|
@AccessibilityFocusState private var isExitDialogFocused: Bool
|
||||||
|
|
||||||
private var callerErrorIsPresented: Binding<Bool> {
|
private var callerErrorIsPresented: Binding<Bool> {
|
||||||
Binding(
|
Binding(
|
||||||
@@ -214,6 +244,7 @@ private struct AuthContentSettingsModifier: ViewModifier {
|
|||||||
}
|
}
|
||||||
.sodaToast(isPresented: callerErrorIsPresented, message: viewModel.errorMessage, autohideIn: 2)
|
.sodaToast(isPresented: callerErrorIsPresented, message: viewModel.errorMessage, autohideIn: 2)
|
||||||
.fullScreenCover(isPresented: $isHostPresented, onDismiss: {
|
.fullScreenCover(isPresented: $isHostPresented, onDismiss: {
|
||||||
|
dismissExitDialog()
|
||||||
panelProgress = 0
|
panelProgress = 0
|
||||||
viewModel.submitPendingAuthVerification()
|
viewModel.submitPendingAuthVerification()
|
||||||
}) {
|
}) {
|
||||||
@@ -225,7 +256,8 @@ private struct AuthContentSettingsModifier: ViewModifier {
|
|||||||
AuthContentSettingsSheet(
|
AuthContentSettingsSheet(
|
||||||
isAdultContentVisible: $viewModel.authIsAdultContentVisible,
|
isAdultContentVisible: $viewModel.authIsAdultContentVisible,
|
||||||
contentType: $viewModel.authContentType,
|
contentType: $viewModel.authContentType,
|
||||||
onConfirm: viewModel.confirmAuthContentSettings
|
onConfirm: viewModel.confirmAuthContentSettings,
|
||||||
|
onClose: showExitDialog
|
||||||
)
|
)
|
||||||
.padding(.leading, proxy.safeAreaInsets.leading)
|
.padding(.leading, proxy.safeAreaInsets.leading)
|
||||||
.padding(.trailing, proxy.safeAreaInsets.trailing)
|
.padding(.trailing, proxy.safeAreaInsets.trailing)
|
||||||
@@ -241,6 +273,8 @@ private struct AuthContentSettingsModifier: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
.sodaToast(isPresented: $viewModel.isShowPopup, message: viewModel.errorMessage, autohideIn: 2)
|
||||||
|
.disabled(isExitDialogPresented)
|
||||||
|
.accessibilityHidden(isExitDialogPresented)
|
||||||
.modifier(AuthContentSettingsPanelMotion(
|
.modifier(AuthContentSettingsPanelMotion(
|
||||||
progress: panelProgress,
|
progress: panelProgress,
|
||||||
reduceMotion: reduceMotion,
|
reduceMotion: reduceMotion,
|
||||||
@@ -249,6 +283,11 @@ private struct AuthContentSettingsModifier: ViewModifier {
|
|||||||
}
|
}
|
||||||
.ignoresSafeArea(.container, edges: [.horizontal, .bottom])
|
.ignoresSafeArea(.container, edges: [.horizontal, .bottom])
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if isExitDialogPresented {
|
||||||
|
exitDialog
|
||||||
|
}
|
||||||
|
}
|
||||||
.interactiveDismissDisabled(true)
|
.interactiveDismissDisabled(true)
|
||||||
.presentationBackground(.clear)
|
.presentationBackground(.clear)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -256,6 +295,7 @@ private struct AuthContentSettingsModifier: ViewModifier {
|
|||||||
updatePanel()
|
updatePanel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onDisappear(perform: dismissExitDialog)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if viewModel.isShowAuthContentSettings {
|
if viewModel.isShowAuthContentSettings {
|
||||||
@@ -263,6 +303,9 @@ private struct AuthContentSettingsModifier: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: viewModel.isShowAuthContentSettings) { isPresented in
|
.onChange(of: viewModel.isShowAuthContentSettings) { isPresented in
|
||||||
|
if !isPresented {
|
||||||
|
dismissExitDialog()
|
||||||
|
}
|
||||||
if isPresented && !isHostPresented {
|
if isPresented && !isHostPresented {
|
||||||
setHostPresented(true)
|
setHostPresented(true)
|
||||||
} else if isHostPresented {
|
} else if isHostPresented {
|
||||||
@@ -276,6 +319,45 @@ private struct AuthContentSettingsModifier: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var exitDialog: some View {
|
||||||
|
SodaV2ActionModal(
|
||||||
|
title: I18n.Settings.Content.authExitTitle,
|
||||||
|
message: I18n.Settings.Content.authExitMessage,
|
||||||
|
button1: SodaV2ActionModalButton(
|
||||||
|
label: I18n.Settings.Content.authExitConfirm,
|
||||||
|
action: confirmExit
|
||||||
|
),
|
||||||
|
button2: SodaV2ActionModalButton(
|
||||||
|
label: I18n.Settings.Content.authExitCancel,
|
||||||
|
action: dismissExitDialog
|
||||||
|
),
|
||||||
|
onDimmedTap: dismissExitDialog
|
||||||
|
)
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityFocused($isExitDialogFocused)
|
||||||
|
.accessibilityAction(.escape, dismissExitDialog)
|
||||||
|
.onAppear {
|
||||||
|
isExitDialogFocused = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func showExitDialog() {
|
||||||
|
guard isHostPresented, viewModel.isShowAuthContentSettings, !viewModel.isSubmittingAuth else { return }
|
||||||
|
isExitDialogPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissExitDialog() {
|
||||||
|
isExitDialogPresented = false
|
||||||
|
isExitDialogFocused = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmExit() {
|
||||||
|
guard isExitDialogPresented, isHostPresented, viewModel.isShowAuthContentSettings, !viewModel.isSubmittingAuth else { return }
|
||||||
|
dismissExitDialog()
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
private func setHostPresented(_ isPresented: Bool) {
|
private func setHostPresented(_ isPresented: Bool) {
|
||||||
var transaction = Transaction()
|
var transaction = Transaction()
|
||||||
transaction.disablesAnimations = true
|
transaction.disablesAnimations = true
|
||||||
@@ -323,19 +405,19 @@ private struct AuthContentSettingsPanelMotion: GeometryEffect {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension View {
|
extension View {
|
||||||
func authContentSettings(viewModel: MyPageViewModel) -> some View {
|
func authContentSettings(viewModel: MyPageViewModel, onCancel: @escaping () -> Void = {}) -> some View {
|
||||||
modifier(AuthContentSettingsModifier(viewModel: viewModel))
|
modifier(AuthContentSettingsModifier(viewModel: viewModel, onCancel: onCancel))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AuthContentSettingsSheet_Previews: PreviewProvider {
|
struct AuthContentSettingsSheet_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
Group {
|
Group {
|
||||||
AuthContentSettingsSheet(isAdultContentVisible: .constant(true), contentType: .constant(.ALL), onConfirm: {})
|
AuthContentSettingsSheet(isAdultContentVisible: .constant(true), contentType: .constant(.ALL), onConfirm: {}, onClose: {})
|
||||||
.frame(width: 402)
|
.frame(width: 402)
|
||||||
.previewDisplayName("ON / ALL")
|
.previewDisplayName("ON / ALL")
|
||||||
|
|
||||||
AuthContentSettingsSheet(isAdultContentVisible: .constant(false), contentType: .constant(.ALL), onConfirm: {})
|
AuthContentSettingsSheet(isAdultContentVisible: .constant(false), contentType: .constant(.ALL), onConfirm: {}, onClose: {})
|
||||||
.frame(width: 402)
|
.frame(width: 402)
|
||||||
.previewDisplayName("OFF / ALL")
|
.previewDisplayName("OFF / ALL")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ struct CreatorChannelView: View {
|
|||||||
.fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) {
|
.fullScreenCover(isPresented: $isShowAuthView, onDismiss: mypageViewModel.presentAuthContentSettings) {
|
||||||
authView
|
authView
|
||||||
}
|
}
|
||||||
.authContentSettings(viewModel: mypageViewModel)
|
.authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil })
|
||||||
.onAppear {
|
.onAppear {
|
||||||
configurePayload()
|
configurePayload()
|
||||||
if viewModel.hasLoaded == false {
|
if viewModel.hasLoaded == false {
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ struct MainView: View {
|
|||||||
autohideIn: 2
|
autohideIn: 2
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.authContentSettings(viewModel: mypageViewModel)
|
.authContentSettings(viewModel: mypageViewModel, onCancel: { pendingAction = nil })
|
||||||
}
|
}
|
||||||
|
|
||||||
private var shouldShowCreatorActionMenu: Bool {
|
private var shouldShowCreatorActionMenu: Bool {
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
struct Response {
|
||||||
|
let statusCode: Int
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MoyaError: Error {
|
||||||
|
case simulatedTransportFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AuthExitIO {
|
||||||
|
static var requests: [AuthVerifyRequest] = []
|
||||||
|
static var authSubjects: [PassthroughSubject<Response, MoyaError>] = []
|
||||||
|
static var memberSubjects: [PassthroughSubject<Response, MoyaError>] = []
|
||||||
|
static var noticeSubjects: [PassthroughSubject<Response, MoyaError>] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AuthRepository {
|
||||||
|
func authCertificate(request: AuthVerifyRequest) -> AnyPublisher<Response, MoyaError> {
|
||||||
|
precondition(Thread.isMainThread)
|
||||||
|
AuthExitIO.requests.append(request)
|
||||||
|
let subject = PassthroughSubject<Response, MoyaError>()
|
||||||
|
AuthExitIO.authSubjects.append(subject)
|
||||||
|
return subject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UserRepository {
|
||||||
|
func getMypage() -> AnyPublisher<Response, MoyaError> {
|
||||||
|
fatalError("Unexpected My Page request in auth exit check")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMemberInfo() -> AnyPublisher<Response, MoyaError> {
|
||||||
|
precondition(Thread.isMainThread)
|
||||||
|
let subject = PassthroughSubject<Response, MoyaError>()
|
||||||
|
AuthExitIO.memberSubjects.append(subject)
|
||||||
|
return subject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class NoticeRepository {
|
||||||
|
func getLatestNotice() -> AnyPublisher<Response, MoyaError> {
|
||||||
|
let subject = PassthroughSubject<Response, MoyaError>()
|
||||||
|
AuthExitIO.noticeSubjects.append(subject)
|
||||||
|
return subject.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppStep {
|
||||||
|
case splash
|
||||||
|
}
|
||||||
|
|
||||||
|
final class AppState {
|
||||||
|
static let shared = AppState()
|
||||||
|
var isRestartApp = false
|
||||||
|
var restartCount = 0
|
||||||
|
|
||||||
|
func setAppStep(step: AppStep) {
|
||||||
|
precondition(Thread.isMainThread)
|
||||||
|
restartCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UserDefaults {
|
||||||
|
static let standard = UserDefaults()
|
||||||
|
var values: [String: Any] = [:]
|
||||||
|
var writes: [String] = []
|
||||||
|
|
||||||
|
func set(_ value: Any, forKey key: String) {
|
||||||
|
precondition(Thread.isMainThread)
|
||||||
|
values[key] = value
|
||||||
|
writes.append(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func object(forKey key: String) -> Any? { values[key] }
|
||||||
|
func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false }
|
||||||
|
func string(forKey key: String) -> String? { values[key] as? String }
|
||||||
|
func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 }
|
||||||
|
func data(forKey key: String) -> Data? { values[key] as? Data }
|
||||||
|
func removeObject(forKey key: String) {
|
||||||
|
values.removeValue(forKey: key)
|
||||||
|
writes.append(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum I18n {
|
||||||
|
enum Common {
|
||||||
|
static let commonError = "commonError"
|
||||||
|
}
|
||||||
|
enum Main {
|
||||||
|
enum Auth {
|
||||||
|
static let authenticationError = "authenticationError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enum MyPage {
|
||||||
|
enum Auth {
|
||||||
|
static let verificationErrorWithSupport = "verificationErrorWithSupport"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DEBUG_LOG(_ message: String) {}
|
||||||
|
func ERROR_LOG(_ message: String) {}
|
||||||
|
|
||||||
|
private final class CallbackLifetime {}
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct AuthContentSettingsExitCheck {
|
||||||
|
static let authSuccess = #"{"success":true,"data":{"gender":0}}"#
|
||||||
|
static let memberSuccess = #"{"success":true,"data":{"can":13,"point":21,"isAuth":true,"gender":null,"signupDate":"synthetic-date","chargeCount":2,"role":"CREATOR","messageNotice":false,"followingChannelLiveNotice":true,"followingChannelUploadContentNotice":false,"auditionNotice":true,"countryCode":"KR","isAdultContentVisible":true,"contentType":"FEMALE"}}"#
|
||||||
|
static let persistedSettings: [String: Any] = [
|
||||||
|
"can": 3, "point": 5, "auth": false, "role": "USER",
|
||||||
|
"isAuditionNotification": false, "countryCode": "JP",
|
||||||
|
"isAdultContentVisible": false, "contentPreference": "MALE"
|
||||||
|
]
|
||||||
|
|
||||||
|
static func expect(_ condition: @autoclosure () -> Bool, _ message: String) {
|
||||||
|
if !condition() {
|
||||||
|
fputs("FAIL: \(message)\n", stderr)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func wait(_ message: String, until condition: () -> Bool) {
|
||||||
|
let deadline = Date().addingTimeInterval(2)
|
||||||
|
while !condition() && Date() < deadline {
|
||||||
|
RunLoop.main.run(until: Date().addingTimeInterval(0.001))
|
||||||
|
}
|
||||||
|
expect(condition(), "Timed out: \(message)")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func drainMainQueue() {
|
||||||
|
var drained = false
|
||||||
|
DispatchQueue.main.async { drained = true }
|
||||||
|
wait("main queue drain") { drained }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func reset() {
|
||||||
|
AuthExitIO.requests = []
|
||||||
|
AuthExitIO.authSubjects = []
|
||||||
|
AuthExitIO.memberSubjects = []
|
||||||
|
AuthExitIO.noticeSubjects = []
|
||||||
|
UserDefaults.standard.values = persistedSettings
|
||||||
|
UserDefaults.standard.writes = []
|
||||||
|
AppState.shared.isRestartApp = false
|
||||||
|
AppState.shared.restartCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func receipt(_ identifier: String) -> [String: Any] {
|
||||||
|
["data": ["receipt_id": identifier]]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func begin(onSuccess: (() -> Void)? = nil) -> MyPageViewModel {
|
||||||
|
let viewModel = MyPageViewModel()
|
||||||
|
viewModel.authVerify(receipt("synthetic-old-receipt"), onSuccess: onSuccess)
|
||||||
|
viewModel.presentAuthContentSettings()
|
||||||
|
expect(viewModel.isShowAuthContentSettings, "Valid receipt must open settings")
|
||||||
|
return viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
static func send(_ subject: PassthroughSubject<Response, MoyaError>, json: String, status: Int = 200) {
|
||||||
|
subject.send(Response(statusCode: status, data: Data(json.utf8)))
|
||||||
|
subject.send(completion: .finished)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func expectNoEffects(posts: Int, _ context: String) {
|
||||||
|
expect(AuthExitIO.requests.count == posts, "\(context): unexpected auth POST")
|
||||||
|
expect(AuthExitIO.memberSubjects.isEmpty, "\(context): unexpected member GET")
|
||||||
|
expect(UserDefaults.standard.writes.isEmpty, "\(context): persisted settings changed")
|
||||||
|
expect(NSDictionary(dictionary: UserDefaults.standard.values).isEqual(to: persistedSettings), "\(context): existing settings lost")
|
||||||
|
expect(!AppState.shared.isRestartApp && AppState.shared.restartCount == 0, "\(context): unexpected restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func exitAndDismiss(_ viewModel: MyPageViewModel, posts: Int, context: String) {
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
expect(!viewModel.isShowAuthContentSettings && !viewModel.isSubmittingAuth, "\(context): exit must close idle panel")
|
||||||
|
expect(!viewModel.isShowPopup, "\(context): old error toast leaked")
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
viewModel.presentAuthContentSettings()
|
||||||
|
viewModel.confirmAuthContentSettings()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
drainMainQueue()
|
||||||
|
expect(!viewModel.isShowAuthContentSettings && !viewModel.isSubmittingAuth, "\(context): discarded receipt was reused")
|
||||||
|
expectNoEffects(posts: posts, context)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func completeFreshAuthentication(_ viewModel: MyPageViewModel, context: String) {
|
||||||
|
let priorPosts = AuthExitIO.requests.count
|
||||||
|
var freshCallbackCount = 0
|
||||||
|
viewModel.authVerify(receipt("synthetic-fresh-receipt")) {
|
||||||
|
expect(Thread.isMainThread, "\(context): callback must run on main")
|
||||||
|
expect(!viewModel.isSubmittingAuth && !viewModel.isShowAuthContentSettings, "\(context): callback preceded flow cleanup")
|
||||||
|
expect(UserDefaults.standard.writes.count == 8, "\(context): callback preceded member persistence")
|
||||||
|
expect(UserDefaults.int(forKey: .can) == 13 && UserDefaults.int(forKey: .point) == 21, "\(context): member balance not synchronized")
|
||||||
|
expect(UserDefaults.bool(forKey: .auth) && viewModel.isAuth, "\(context): auth not synchronized")
|
||||||
|
expect(UserDefaults.string(forKey: .role) == "CREATOR" && UserDefaults.bool(forKey: .isAuditionNotification), "\(context): member role/notification not synchronized")
|
||||||
|
expect(UserDefaults.string(forKey: .countryCode) == "KR", "\(context): member country not synchronized")
|
||||||
|
expect(UserDefaults.bool(forKey: .isAdultContentVisible) && UserDefaults.string(forKey: .contentPreference) == "FEMALE", "\(context): member content not synchronized")
|
||||||
|
freshCallbackCount += 1
|
||||||
|
}
|
||||||
|
viewModel.presentAuthContentSettings()
|
||||||
|
expect(viewModel.isShowAuthContentSettings && viewModel.authIsAdultContentVisible && viewModel.authContentType == .ALL, "\(context): fresh receipt must start ON/ALL")
|
||||||
|
expect(!viewModel.isShowPopup, "\(context): fresh flow inherited old toast")
|
||||||
|
viewModel.confirmAuthContentSettings()
|
||||||
|
expectNoEffects(posts: priorPosts, "\(context): before host dismissal")
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
expect(AuthExitIO.requests.count == priorPosts + 1, "\(context): fresh POST must occur once")
|
||||||
|
let request = AuthExitIO.requests.last!
|
||||||
|
expect(request.receiptId == "synthetic-fresh-receipt" && request.isAdultContentVisible && request.contentType == .ALL, "\(context): stale receipt/draft submitted")
|
||||||
|
send(AuthExitIO.authSubjects.last!, json: authSuccess)
|
||||||
|
wait("\(context): member GET") { AuthExitIO.memberSubjects.count == 1 }
|
||||||
|
expect(freshCallbackCount == 0 && UserDefaults.standard.writes.isEmpty, "\(context): callback/persistence preceded GET completion")
|
||||||
|
send(AuthExitIO.memberSubjects[0], json: memberSuccess)
|
||||||
|
wait("\(context): fresh callback") { freshCallbackCount == 1 }
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
viewModel.presentAuthContentSettings()
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
drainMainQueue()
|
||||||
|
expect(freshCallbackCount == 1 && AuthExitIO.requests.count == priorPosts + 1 && AuthExitIO.memberSubjects.count == 1, "\(context): completed flow repeated")
|
||||||
|
expect(!AppState.shared.isRestartApp && AppState.shared.restartCount == 0, "\(context): successful callback route restarted")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func checkInitialExit() {
|
||||||
|
for callbackRoute in [false, true] {
|
||||||
|
reset()
|
||||||
|
var oldCallbackCount = 0
|
||||||
|
var lifetime: CallbackLifetime? = CallbackLifetime()
|
||||||
|
weak var retainedLifetime = lifetime
|
||||||
|
let viewModel = begin(onSuccess: callbackRoute ? { [owner = lifetime!] in
|
||||||
|
withExtendedLifetime(owner) { oldCallbackCount += 1 }
|
||||||
|
} : nil)
|
||||||
|
lifetime = nil
|
||||||
|
viewModel.authIsAdultContentVisible = false
|
||||||
|
viewModel.isLoading = true
|
||||||
|
viewModel.isShowPopup = true
|
||||||
|
exitAndDismiss(viewModel, posts: 0, context: "initial exit")
|
||||||
|
expect(retainedLifetime == nil, "Initial exit retained old success closure")
|
||||||
|
expect(viewModel.isLoading, "Cancellation changed unrelated loading state")
|
||||||
|
completeFreshAuthentication(viewModel, context: "initial re-entry")
|
||||||
|
expect(oldCallbackCount == 0, "Initial exit executed old callback")
|
||||||
|
}
|
||||||
|
print("PASS: initial exit (My Page/callback), repeat exit/host dismissal have no effects; fresh ON/ALL and fresh callback once after sync")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func checkFailureExit() {
|
||||||
|
let failures: [(String, String?, Int)] = [
|
||||||
|
("transport", nil, 200),
|
||||||
|
("HTTP", authSuccess, 503),
|
||||||
|
("envelope", #"{"success":false,"data":null}"#, 200),
|
||||||
|
("decode", "invalid-json", 200),
|
||||||
|
("missing data", #"{"success":true,"data":null}"#, 200),
|
||||||
|
("invalid DTO", #"{"success":true,"data":{}}"#, 200)
|
||||||
|
]
|
||||||
|
for (name, json, status) in failures {
|
||||||
|
reset()
|
||||||
|
var oldCallbackCount = 0
|
||||||
|
let viewModel = begin { oldCallbackCount += 1 }
|
||||||
|
viewModel.authContentType = .FEMALE
|
||||||
|
viewModel.confirmAuthContentSettings()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
expect(AuthExitIO.requests.count == 1, "\(name): expected one attempted POST")
|
||||||
|
if let json = json {
|
||||||
|
send(AuthExitIO.authSubjects[0], json: json, status: status)
|
||||||
|
} else {
|
||||||
|
AuthExitIO.authSubjects[0].send(completion: .failure(.simulatedTransportFailure))
|
||||||
|
}
|
||||||
|
wait("\(name): failure panel") { viewModel.isShowAuthContentSettings && !viewModel.isSubmittingAuth }
|
||||||
|
expect(viewModel.isShowPopup && viewModel.authIsAdultContentVisible && viewModel.authContentType == .FEMALE, "\(name): failure did not reopen same draft with error")
|
||||||
|
exitAndDismiss(viewModel, posts: 1, context: "\(name) failure exit")
|
||||||
|
completeFreshAuthentication(viewModel, context: "\(name) failure re-entry")
|
||||||
|
expect(oldCallbackCount == 0, "\(name): old callback executed after cancellation")
|
||||||
|
print("PASS: \(name) failure reopens same draft; exit adds no effects; fresh receipt/callback succeeds")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func checkSubmissionGuards() {
|
||||||
|
reset()
|
||||||
|
var callbackCount = 0
|
||||||
|
let viewModel = begin { callbackCount += 1 }
|
||||||
|
viewModel.authContentType = .MALE
|
||||||
|
viewModel.confirmAuthContentSettings()
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
expect(viewModel.isSubmittingAuth && !viewModel.isShowAuthContentSettings, "Cancel after confirm discarded pending submission")
|
||||||
|
expectNoEffects(posts: 0, "confirmed before host dismissal")
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
viewModel.confirmAuthContentSettings()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
expect(viewModel.isSubmittingAuth && AuthExitIO.requests.count == 1, "Cancel during POST changed submission or duplicated POST")
|
||||||
|
expect(AuthExitIO.requests[0].contentType == .MALE, "Cancel after confirm lost request draft")
|
||||||
|
send(AuthExitIO.authSubjects[0], json: authSuccess)
|
||||||
|
wait("guard check member GET") { AuthExitIO.memberSubjects.count == 1 }
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
expect(viewModel.isSubmittingAuth && callbackCount == 0 && UserDefaults.standard.writes.isEmpty, "Cancel during GET interrupted synchronization")
|
||||||
|
send(AuthExitIO.memberSubjects[0], json: memberSuccess)
|
||||||
|
wait("guard check callback") { callbackCount == 1 }
|
||||||
|
drainMainQueue()
|
||||||
|
expect(AuthExitIO.requests.count == 1 && AuthExitIO.memberSubjects.count == 1 && callbackCount == 1, "Submission guard flow repeated")
|
||||||
|
expect(!AppState.shared.isRestartApp && AppState.shared.restartCount == 0, "Submission guard flow restarted")
|
||||||
|
print("PASS: cancel ignored after confirm/before dismissal, during POST and during GET; original callback completes once")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func checkMyPageSave() {
|
||||||
|
reset()
|
||||||
|
let viewModel = begin()
|
||||||
|
viewModel.authContentType = .FEMALE
|
||||||
|
viewModel.confirmAuthContentSettings()
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
expectNoEffects(posts: 0, "My Page before dismissal")
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
expect(AuthExitIO.requests.count == 1, "My Page save POST must occur once after dismissal")
|
||||||
|
send(AuthExitIO.authSubjects[0], json: authSuccess)
|
||||||
|
wait("My Page restart") { AppState.shared.restartCount == 1 }
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
drainMainQueue()
|
||||||
|
expect(AuthExitIO.requests.count == 1 && AuthExitIO.memberSubjects.isEmpty, "My Page save added POST/GET")
|
||||||
|
expect(UserDefaults.standard.writes == ["isAdultContentVisible", "contentPreference"], "My Page save persistence changed")
|
||||||
|
expect(UserDefaults.bool(forKey: .isAdultContentVisible) && UserDefaults.string(forKey: .contentPreference) == "FEMALE", "My Page save lost confirmed settings")
|
||||||
|
expect(!viewModel.isSubmittingAuth && AppState.shared.isRestartApp && AppState.shared.restartCount == 1, "My Page save restart contract changed")
|
||||||
|
print("PASS: existing My Page save POST once after dismissal, confirmed settings persisted, restart once without GET")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func checkMainThreadAndHiddenPanel() {
|
||||||
|
reset()
|
||||||
|
let viewModel = MyPageViewModel()
|
||||||
|
viewModel.authVerify(receipt("synthetic-hidden-receipt"))
|
||||||
|
viewModel.isShowPopup = true
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
expect(viewModel.isShowPopup, "Hidden-panel cancel changed unrelated toast")
|
||||||
|
viewModel.presentAuthContentSettings()
|
||||||
|
expect(viewModel.isShowAuthContentSettings, "Hidden-panel cancel discarded receipt")
|
||||||
|
viewModel.getLatestNotice()
|
||||||
|
var cancellationOnMain: Bool?
|
||||||
|
let observation = viewModel.$isShowAuthContentSettings.dropFirst().sink { shown in
|
||||||
|
if !shown { cancellationOnMain = Thread.isMainThread }
|
||||||
|
}
|
||||||
|
let dispatched = DispatchSemaphore(value: 0)
|
||||||
|
DispatchQueue.global().async {
|
||||||
|
viewModel.cancelAuthContentSettings()
|
||||||
|
dispatched.signal()
|
||||||
|
}
|
||||||
|
expect(dispatched.wait(timeout: .now() + 2) == .success, "Background cancellation did not return")
|
||||||
|
wait("background cancellation") { cancellationOnMain != nil }
|
||||||
|
expect(cancellationOnMain == true, "Background cancellation mutated panel off main")
|
||||||
|
send(AuthExitIO.noticeSubjects[0], json: #"{"success":true,"data":{"title":"Synthetic notice","content":"Synthetic content","date":"synthetic-date"}}"#)
|
||||||
|
expect(viewModel.latestNotice?.title == "Synthetic notice", "Cancellation removed unrelated shared subscriptions")
|
||||||
|
viewModel.submitPendingAuthVerification()
|
||||||
|
drainMainQueue()
|
||||||
|
expectNoEffects(posts: 0, "background exit")
|
||||||
|
expect(!viewModel.isShowPopup, "Background exit left toast visible")
|
||||||
|
withExtendedLifetime(observation) {}
|
||||||
|
print("PASS: hidden-panel cancellation ignored; background cancellation dispatches to main and preserves unrelated subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func main() {
|
||||||
|
expect(Thread.isMainThread, "Harness must run on main thread")
|
||||||
|
checkInitialExit()
|
||||||
|
checkFailureExit()
|
||||||
|
checkSubmissionGuards()
|
||||||
|
checkMyPageSave()
|
||||||
|
checkMainThreadAndHiddenPanel()
|
||||||
|
print("PASS: actual MyPageViewModel/production DTO exit regression check (native Combine, isolated repository/AppState/UserDefaults doubles)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/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 -module-cache-path "$TEMP_DIR/module-cache" \
|
||||||
|
"$ROOT/SodaLive/Sources/MyPage/MyPageViewModel.swift" \
|
||||||
|
"$ROOT/SodaLive/Sources/MyPage/Auth/AuthVerifyRequest.swift" \
|
||||||
|
"$ROOT/SodaLive/Sources/MyPage/Auth/AuthResponse.swift" \
|
||||||
|
"$ROOT/SodaLive/Sources/MyPage/MyPageResponse.swift" \
|
||||||
|
"$ROOT/SodaLive/Sources/Common/ApiResponse.swift" \
|
||||||
|
"$ROOT/SodaLive/Sources/Settings/Content/ContentType.swift" \
|
||||||
|
"$ROOT/SodaLive/Sources/Settings/Notification/GetMemberInfoResponse.swift" \
|
||||||
|
"$ROOT/SodaLive/Sources/Settings/Notice/GetNoticeResponse.swift" \
|
||||||
|
"$ROOT/SodaLive/Sources/Extensions/UserDefaultsExtension.swift" \
|
||||||
|
"$ROOT/work/tests/AuthContentSettingsExitCheck.swift" \
|
||||||
|
-o "$TEMP_DIR/check-auth-content-settings-exit"
|
||||||
|
"$TEMP_DIR/check-auth-content-settings-exit"
|
||||||
Reference in New Issue
Block a user