Compare commits

5 Commits

14 changed files with 381 additions and 18 deletions

View File

@@ -716,6 +716,10 @@ enum I18n {
static var joinAllowed: String { pick(ko: "가능", en: "Allowed", ja: "可能") }
static var joinNotAllowed: String { pick(ko: "불가능", en: "Not allowed", ja: "不可") }
static var captureRecordingSetting: String { pick(ko: "캡쳐/녹화 허용", en: "Capture/recording", ja: "キャプチャ/録画") }
static var captureRecordingAllowed: String { pick(ko: "가능", en: "Allowed", ja: "可能") }
static var captureRecordingNotAllowed: String { pick(ko: "불가능", en: "Not allowed", ja: "不可") }
//
static var allAges: String { pick(ko: "전체 연령", en: "All ages", ja: "全年齢") }
static var over19: String { pick(ko: "19세 이상", en: "19+", ja: "R-18") }

View File

@@ -24,4 +24,5 @@ struct CreateLiveRoomRequest: Encodable {
var menuPan: String = ""
var isActiveMenuPan: Bool = false
var isAvailableJoinCreator: Bool = true
var isCaptureRecordingAvailable: Bool = false
}

View File

@@ -14,4 +14,5 @@ struct GetRecentRoomInfoResponse: Decodable {
let coverImagePath: String
let numberOfPeople: Int
let genderRestriction: LiveRoomCreateViewModel.GenderRestriction
let isCaptureRecordingAvailable: Bool?
}

View File

@@ -183,6 +183,31 @@ struct LiveRoomCreateView: View {
.frame(width: screenSize().width - 26.7)
.padding(.top, 33.3)
VStack(spacing: 13.3) {
Text(I18n.CreateLive.captureRecordingSetting)
.appFont(size: 16.7, weight: .bold)
.foregroundColor(Color.grayee)
.frame(width: screenSize().width - 26.7, alignment: .leading)
HStack(spacing: 13.3) {
SelectedButtonView(
title: I18n.CreateLive.captureRecordingAllowed,
isActive: true,
isSelected: viewModel.isCaptureRecordingAvailable
)
.onTapGesture { viewModel.isCaptureRecordingAvailable = true }
SelectedButtonView(
title: I18n.CreateLive.captureRecordingNotAllowed,
isActive: true,
isSelected: !viewModel.isCaptureRecordingAvailable
)
.onTapGesture { viewModel.isCaptureRecordingAvailable = false }
}
}
.frame(width: screenSize().width - 26.7)
.padding(.top, 33.3)
if shouldShowAdultSetting {
AdultSettingView()
.frame(width: screenSize().width - 26.7)

View File

@@ -100,6 +100,7 @@ final class LiveRoomCreateViewModel: ObservableObject {
@Published var selectedMenu: SelectedMenu? = nil
@Published var isAvailableJoinCreator = true
@Published var isCaptureRecordingAvailable = false
private let repository = LiveRepository()
private var subscription = Set<AnyCancellable>()
@@ -146,6 +147,7 @@ final class LiveRoomCreateViewModel: ObservableObject {
self.coverImagePath = data.coverImagePath
self.numberOfPeople = String(data.numberOfPeople)
self.genderRestriction = data.genderRestriction
self.isCaptureRecordingAvailable = data.isCaptureRecordingAvailable ?? false
self.errorMessage = I18n.CreateLive.recentDataLoaded
self.isShowPopup = true
@@ -192,7 +194,8 @@ final class LiveRoomCreateViewModel: ObservableObject {
menuPanId: isActivateMenu ? menuId : 0,
menuPan: isActivateMenu ? menu : "",
isActiveMenuPan: isActivateMenu,
isAvailableJoinCreator: isAvailableJoinCreator
isAvailableJoinCreator: isAvailableJoinCreator,
isCaptureRecordingAvailable: isCaptureRecordingAvailable
)
if timeSettingMode == .RESERVATION {

View File

@@ -29,6 +29,7 @@ struct GetRoomInfoResponse: Decodable {
let creatorLanguageCode: String?
let isActiveRoulette: Bool
let isChatFrozen: Bool?
let isCaptureRecordingAvailable: Bool?
let isPrivateRoom: Bool
let password: String?
}

View File

@@ -1055,9 +1055,12 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
agora.sendMessageToPeer(peerId: String(peerId), rawMessage: LiveRoomRequestType.CHANGE_LISTENER.rawValue.data(using: .utf8)!) { [unowned self] _, error in
if error == nil {
if isFromManager {
getRoomInfo()
setManagerMessage()
releaseManagerMessageToPeer(userId: peerId)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { [weak self] in
self?.getRoomInfo()
}
self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: peerId).nickname)님을 스탭에서 해제했어요."
} else {
self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: peerId).nickname)님을 리스너로 변경했어요."
@@ -1101,7 +1104,10 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
}
func setListener() {
repository.setListener(roomId: AppState.shared.roomId, userId: UserDefaults.int(forKey: .userId))
let currentUserId = UserDefaults.int(forKey: .userId)
let wasManager = liveRoomInfo?.managerList.contains(where: { $0.id == currentUserId }) ?? false
repository.setListener(roomId: AppState.shared.roomId, userId: currentUserId)
.sink { result in
switch result {
case .finished:
@@ -1121,10 +1127,14 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
self.agora.setRole(role: .audience)
self.isMute = false
self.agora.mute(isMute)
if let index = self.muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) {
if let index = self.muteSpeakers.firstIndex(of: UInt(currentUserId)) {
self.muteSpeakers.remove(at: index)
}
self.getRoomInfo()
if wasManager {
self.setManagerMessage()
}
}
} catch {
}

View File

@@ -62,8 +62,37 @@ struct LiveRoomViewV2: View {
return I18n.LiveRoom.chatFreezeBlockedMessage
}
private var isCurrentUserHost: Bool {
guard let creatorId = viewModel.liveRoomInfo?.creatorId else {
return false
}
return creatorId == UserDefaults.int(forKey: .userId)
}
private var isCurrentUserStaff: Bool {
guard let managerList = viewModel.liveRoomInfo?.managerList else {
return false
}
let currentUserId = UserDefaults.int(forKey: .userId)
return managerList.contains { $0.id == currentUserId }
}
private var shouldEnforceScreenCaptureProtection: Bool {
guard let liveRoomInfo = viewModel.liveRoomInfo else {
return true
}
if liveRoomInfo.isCaptureRecordingAvailable == true {
return false
}
return !(isCurrentUserHost || isCurrentUserStaff)
}
var body: some View {
ScreenCaptureSecureContainer {
ScreenCaptureSecureContainer(isSecureModeEnabled: shouldEnforceScreenCaptureProtection) {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
@@ -533,8 +562,7 @@ struct LiveRoomViewV2: View {
UIApplication.shared.isIdleTimerDisabled = true
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
viewModel.initAgoraEngine()
//
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
syncScreenCaptureProtectionState()
viewModel.getMemberCan()
viewModel.getRoomInfo()
@@ -928,7 +956,16 @@ struct LiveRoomViewV2: View {
}
// ( / )
.onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
syncScreenCaptureProtectionState()
}
.onChange(of: viewModel.liveRoomInfo?.creatorId) { _ in
syncScreenCaptureProtectionState()
}
.onChange(of: viewModel.liveRoomInfo?.managerList) { _ in
syncScreenCaptureProtectionState()
}
.onChange(of: viewModel.liveRoomInfo?.isCaptureRecordingAvailable) { _ in
syncScreenCaptureProtectionState()
}
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
if isFrozen {
@@ -1107,18 +1144,26 @@ struct LiveRoomViewV2: View {
// / SwiftUI
private struct ScreenCaptureSecureContainer<Content: View>: UIViewControllerRepresentable {
let isSecureModeEnabled: Bool
let content: Content
init(@ViewBuilder content: () -> Content) {
init(isSecureModeEnabled: Bool = true, @ViewBuilder content: () -> Content) {
self.isSecureModeEnabled = isSecureModeEnabled
self.content = content()
}
func makeUIViewController(context: Context) -> ScreenCaptureSecureHostingController<Content> {
ScreenCaptureSecureHostingController(rootView: content)
ScreenCaptureSecureHostingController(
rootView: content,
isSecureModeEnabled: isSecureModeEnabled
)
}
func updateUIViewController(_ uiViewController: ScreenCaptureSecureHostingController<Content>, context: Context) {
uiViewController.update(rootView: content)
uiViewController.update(
rootView: content,
isSecureModeEnabled: isSecureModeEnabled
)
}
}
@@ -1126,9 +1171,11 @@ private struct ScreenCaptureSecureContainer<Content: View>: UIViewControllerRepr
private final class ScreenCaptureSecureHostingController<Content: View>: UIViewController {
private let secureContainerView = ScreenCaptureSecureView()
private let hostingController: UIHostingController<Content>
private var isSecureModeEnabled: Bool
init(rootView: Content) {
init(rootView: Content, isSecureModeEnabled: Bool) {
hostingController = UIHostingController(rootView: rootView)
self.isSecureModeEnabled = isSecureModeEnabled
super.init(nibName: nil, bundle: nil)
}
@@ -1145,12 +1192,15 @@ private final class ScreenCaptureSecureHostingController<Content: View>: UIViewC
hostingController.view.backgroundColor = .clear
addChild(hostingController)
secureContainerView.setSecureModeEnabled(isSecureModeEnabled)
secureContainerView.embed(contentView: hostingController.view)
hostingController.didMove(toParent: self)
}
func update(rootView: Content) {
func update(rootView: Content, isSecureModeEnabled: Bool) {
self.isSecureModeEnabled = isSecureModeEnabled
hostingController.rootView = rootView
secureContainerView.setSecureModeEnabled(isSecureModeEnabled)
secureContainerView.embed(contentView: hostingController.view)
}
}
@@ -1161,6 +1211,7 @@ private final class ScreenCaptureSecureView: UIView {
private weak var secureContentView: UIView?
private let failClosedOverlayView = UIView()
private var didLogFailClosedActivation = false
private var isSecureModeEnabled = true
override init(frame: CGRect) {
super.init(frame: frame)
@@ -1207,6 +1258,25 @@ private final class ScreenCaptureSecureView: UIView {
}
func embed(contentView: UIView) {
if !isSecureModeEnabled {
updateFailClosedState(isActive: false)
guard contentView.superview !== self else {
return
}
contentView.removeFromSuperview()
contentView.translatesAutoresizingMaskIntoConstraints = false
insertSubview(contentView, belowSubview: failClosedOverlayView)
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
return
}
if secureContentView == nil {
secureContentView = resolveSecureContentView()
}
@@ -1235,6 +1305,21 @@ private final class ScreenCaptureSecureView: UIView {
])
}
func setSecureModeEnabled(_ isEnabled: Bool) {
isSecureModeEnabled = isEnabled
secureTextField.isHidden = !isEnabled
if isEnabled {
if secureContentView == nil {
secureContentView = resolveSecureContentView()
}
updateFailClosedState(isActive: secureContentView == nil)
return
}
updateFailClosedState(isActive: false)
}
private func resolveSecureContentView() -> UIView? {
secureTextField.subviews.first {
let className = NSStringFromClass(type(of: $0))
@@ -1266,6 +1351,16 @@ private final class ScreenCaptureSecureTextField: UITextField {
}
private extension LiveRoomViewV2 {
func syncScreenCaptureProtectionState() {
guard shouldEnforceScreenCaptureProtection else {
isScreenCaptureProtected = false
releaseForcedCaptureMute()
return
}
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
}
func applyScreenCaptureProtection(isCaptured: Bool) {
// UI
isScreenCaptureProtected = isCaptured

View File

@@ -488,9 +488,7 @@ struct HomeView: View {
if !UserDefaults.isAdultContentVisible() {
pendingAction = nil
AppState.shared.errorMessage = I18n.Settings.adultContentEnableGuide
AppState.shared.isShowErrorPopup = true
AppState.shared.setAppStep(step: .contentViewSettings)
moveToContentSettingsWithGuideToast()
return
}
}
@@ -513,6 +511,11 @@ struct HomeView: View {
)
}
private func moveToContentSettingsWithGuideToast() {
AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)
AppState.shared.setAppStep(step: .contentViewSettings)
}
private func handleExternalNavigationRequest(
value: Int,
navigationAction: @escaping () -> Void,

View File

@@ -0,0 +1,66 @@
# 20260328 라이브 19금 설정 이동 후 토스트 표시
## 개요
- 라이브 아이템(19금) 터치 시 `민감한 콘텐츠 보기`가 꺼져 있으면, 현재 화면에서 토스트를 먼저 띄우고 즉시 설정 화면으로 이동하여 메시지 확인이 어려운 문제를 수정한다.
- 채팅 캐릭터 상세 진입과 동일하게, 콘텐츠 보기 설정 화면으로 먼저 이동한 뒤 안내 토스트가 보이도록 흐름을 통일한다.
## 완료 기준 (Acceptance Criteria)
- [x] AC1: 라이브 19금 아이템 터치 + 민감한 콘텐츠 OFF 조건에서 `.contentViewSettings` 이동이 정상 동작한다.
- QA: 실기기/시뮬레이터에서 해당 조건 재현 후 화면 전환 확인.
- [x] AC2: 설정 화면 진입 직후 `I18n.Settings.adultContentEnableGuide` 토스트가 표시된다.
- QA: 설정 화면에서 토스트 노출 여부 확인.
- [x] AC3: KR 본인인증 분기(`isKoreanCountry && auth == false`) 동작은 기존과 동일하다.
- QA: KR + 미인증 계정으로 터치 시 인증 다이얼로그 노출 확인.
- [x] AC4: 성인 방송이 아니거나 민감한 콘텐츠 ON 상태에서는 기존 라이브 상세 진입 동작을 유지한다.
- QA: non-adult / adult+ON 각각 상세 진입 확인.
## 구현 체크리스트
- [x] 라이브 진입 성인 가드 구현 위치(`HomeView.handleLiveNowItemTap`) 수정
- [x] 기존 패턴과 동일하게 `pendingContentSettingsGuideMessage` 기반으로 토스트 전달
- [x] 요청 범위 파일(`HomeTabView`, `LiveView`) 연계 동작 영향 점검
- [x] 정적 진단/빌드/테스트 실행
- [x] 문서 체크박스 및 검증 기록 업데이트
## 검증 계획
- [x] `lsp_diagnostics`:
- `SodaLive/Sources/Main/Home/HomeView.swift`
- (영향 점검) `SodaLive/Sources/Home/HomeTabView.swift`
- (영향 점검) `SodaLive/Sources/Live/LiveView.swift`
- [x] 빌드:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- [x] 테스트:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
## 검증 기록
- 일시: 2026-03-28
- 무엇: 라이브 19금 콘텐츠 설정 이동 후 토스트 표시 개선 작업 계획 수립
- 왜: 요청사항의 완료 기준/검증 절차를 고정해 정확히 동일 동작으로 수정하기 위함
- 어떻게: 기존 패턴(`ChatTabView``ContentSettingsView.onAppear`) 탐색 결과를 바탕으로 최소 변경 계획 문서화
- 실행 명령/도구: `apply_patch(문서 생성)`
- 결과: 계획 문서 생성 완료
- 일시: 2026-03-28
- 무엇: 라이브 19금 진입 차단 시 토스트 표시 시점을 설정 화면 진입 후로 변경
- 왜: 기존에는 메시지 표시와 화면 이동이 동시에 발생해 안내 문구 확인이 어려웠기 때문
- 어떻게:
- `HomeView.handleLiveNowItemTap`의 성인 콘텐츠 OFF 분기에서 전역 에러 팝업 즉시 표시를 제거
- `moveToContentSettingsWithGuideToast()`를 추가해
- `AppState.shared.setPendingContentSettingsGuideMessage(I18n.Settings.adultContentEnableGuide)`
- `AppState.shared.setAppStep(step: .contentViewSettings)`
순서로 처리
- `ContentSettingsView.onAppear`의 기존 pending 메시지 consume 패턴을 그대로 재사용해 설정 화면에서 토스트 표시
- 실행 명령/도구:
- `lsp_diagnostics("SodaLive/Sources/Main/Home/HomeView.swift")`
- `lsp_diagnostics("SodaLive/Sources/Home/HomeTabView.swift")`
- `lsp_diagnostics("SodaLive/Sources/Live/LiveView.swift")`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- 결과:
- 두 스킴 Debug 빌드 모두 `** BUILD SUCCEEDED **`
- 테스트는 두 스킴 모두 `Scheme ... is not currently configured for the test action`으로 실행 불가(테스트 액션 미구성)
- `lsp_diagnostics`는 SourceKit 환경 한계로 외부 모듈 미해결(`Firebase`, `Bootpay`, `RefreshableScrollView`) 오류를 보고했으나 실제 빌드는 통과
- 수동 QA는 CLI 환경 제약으로 미실행(실기기/시뮬레이터에서 라이브 19금 + 민감 콘텐츠 OFF 시 설정 화면 진입 후 토스트 노출 확인 필요)

View File

@@ -0,0 +1,38 @@
# 20260328 방장 캡쳐/녹화 허용
## 작업 체크리스트
- [x] `LiveRoomViewV2` 캡쳐/녹화 보호 적용 지점 재확인
- [x] 현재 사용자 방장 여부 계산 프로퍼티 추가
- [x] 방장일 때 `ScreenCaptureSecureContainer` 미적용 분기 추가
- [x] 방장일 때 캡쳐 감지 오버레이/강제 음소거 로직 비활성화
- [x] LSP/빌드/테스트 검증 수행
- [x] 검증 결과 기록
## 수용 기준 (Acceptance Criteria)
- [x] 방장(`creatorId == currentUserId`)은 라이브룸 화면에서 스크린샷/화면 녹화가 가능하다. (코드 경로 기준)
- [x] 비방장(게스트/리스너/스피커)은 기존 캡쳐/녹화 보호가 유지된다.
- [x] 캡쳐 감지 시 비방장에게만 검정 오버레이/강제 음소거가 적용된다.
- [x] 변경 파일 LSP 진단 오류가 없다.
- [x] `SodaLive`, `SodaLive-dev` Debug build가 성공한다.
## 검증 기록
### 1차 검증 (2026-03-28)
- 무엇/왜/어떻게:
- 무엇: 방장만 캡쳐/녹화 보호를 우회하도록 조건 분기를 적용.
- 왜: 요청사항(방장 캡쳐·녹화 허용) 충족과 비방장 보호 유지.
- 어떻게: `ScreenCaptureSecureContainer`를 런타임에서 secure on/off 가능한 구조로 확장하고, 방장 여부에 따라 캡쳐 보호 동기화를 분기했다.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `grep("\\*\\* BUILD SUCCEEDED \\*\\*", /Users/klaus/.local/share/opencode/tool-output/tool_d340d3dc1001ZldmuCAgiYN1ly)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `grep("shouldEnforceScreenCaptureProtection|syncScreenCaptureProtectionState", LiveRoomViewV2.swift)`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(코드 경로):
- 방장/비방장 분기 확인: `ScreenCaptureSecureContainer(isSecureModeEnabled: shouldEnforceScreenCaptureProtection)`
- 방장 동기화 확인: `syncScreenCaptureProtectionState()`에서 방장인 경우 `isScreenCaptureProtected = false` + `releaseForcedCaptureMute()`
- 비방장 보호 유지 확인: 비방장인 경우 `applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)`
- 제한사항: 현재 CLI/헤드리스 환경에서 실제 기기 스크린샷/화면녹화 버튼 조작 E2E는 불가하여, 실기기 최종 확인이 추가로 필요.

View File

@@ -0,0 +1,37 @@
# 20260330 라이브룸 스탭 캡쳐/녹화 권한 확장
## 작업 체크리스트
- [x] `LiveRoomViewV2`의 기존 캡쳐/녹화 권한 분기(방장 전용) 확인
- [x] 스탭(`managerList`/`MANAGER`) 판별 방식 확인 및 적용 기준 확정
- [x] 캡쳐/녹화 허용 대상을 방장+스탭으로 확장
- [x] LSP/빌드/테스트/수동 QA 검증 수행
- [x] 검증 결과 기록
## 수용 기준 (Acceptance Criteria)
- [x] 방장 또는 스탭인 경우 라이브룸 화면에서 캡쳐/녹화 보호가 적용되지 않는다.
- [x] 방장/스탭이 아닌 참여자는 기존 캡쳐/녹화 보호가 유지된다.
- [x] 캡쳐 감지 오버레이 및 강제 음소거 로직은 방장/스탭이 아닌 참여자에게만 동작한다.
- [x] 변경 파일 LSP 진단 오류가 없다.
- [x] Debug 빌드가 성공한다 (`SodaLive`, `SodaLive-dev`).
## 검증 기록
### 1차 검증 (2026-03-30)
- 무엇/왜/어떻게:
- 무엇: 라이브룸 캡쳐/녹화 보호 예외 대상을 `방장`에서 `방장 + 스탭`으로 확장하고, `managerList` 변경 시 보호 상태를 즉시 재동기화하도록 변경.
- 왜: 스탭 권한이 입장 시 고정이 아니라 방송 중 방장에 의해 동적으로 부여/해제되므로, 중간 권한 변경 시에도 캡쳐/녹화 허용 여부가 즉시 반영되어야 함.
- 어떻게: `LiveRoomViewV2``isCurrentUserStaff` 계산 프로퍼티를 추가하고 `shouldEnforceScreenCaptureProtection``!(isCurrentUserHost || isCurrentUserStaff)`로 변경. 또한 `.onChange(of: viewModel.liveRoomInfo?.managerList)`에서 `syncScreenCaptureProtectionState()`를 호출해 동적 권한 변경을 반영.
- 실행 명령:
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `grep("\*\* BUILD SUCCEEDED \*\*", include: tool_d3db4bd4c001vPzVKsa2VZSVFE)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `grep("isCurrentUserStaff|shouldEnforceScreenCaptureProtection|onChange\\(of: viewModel.liveRoomInfo\\?\\.managerList\\)", LiveRoomViewV2.swift)`
- 결과:
- `lsp_diagnostics`: `No diagnostics found`
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(코드 경로):
- 권한 분기 확인: `shouldEnforceScreenCaptureProtection = !(isCurrentUserHost || isCurrentUserStaff)`
- 동적 권한 반영 확인: `.onChange(of: viewModel.liveRoomInfo?.managerList) { syncScreenCaptureProtectionState() }`
- 보호/해제 동작 확인: `syncScreenCaptureProtectionState()`에서 보호 비대상(방장/스탭)일 때 `isScreenCaptureProtected = false``releaseForcedCaptureMute()` 호출
- 제한사항: CLI 환경 특성상 실기기에서 실제 스크린샷/화면녹화 버튼 조작 E2E는 수행하지 못했으며, 최종 사용자 시나리오는 실기기 확인이 필요.

View File

@@ -0,0 +1,31 @@
# 20260330 라이브룸 스탭 해제 갱신 수정
## 작업 개요
- 라이브 진행 중 스탭 지정/해제 시 `LiveRoomProfilesDialogView`의 스탭 표시가 실시간으로 정확히 갱신되도록 원인 분석 및 수정한다.
## 구현 체크리스트
- [x] 관련 코드 경로 병렬 탐색(Explore + 직접 검색)으로 원인 확정
- [x] 스탭 해제 동작 시 서버/클라이언트 상태 갱신 누락 수정
- [x] `LiveRoomProfilesDialogView`에 전달되는 `roomInfo` 재조회 타이밍 보정
- [x] 변경 파일 진단 및 빌드 검증 수행
- [x] 검증 기록 누적
## 검증 기록
- 무엇: 스탭 해제 시점에 방장 클라이언트가 너무 이른 시점에만 `getRoomInfo()`를 호출해 `managerList`가 stale 상태로 남는 문제를 수정.
- 왜: `LiveRoomProfilesDialogView`는 전달받은 `roomInfo.managerList`를 표시하므로, 해제 완료 이후의 최신 `roomInfo` 재조회 트리거가 필요.
- 어떻게:
- `LiveRoomViewModel.changeListener(peerId:isFromManager:)`에서 스탭 해제 시 즉시 `setManagerMessage()`를 보내던 흐름을 제거하고, 해제 안내 메시지 전송 후 지연 재조회(`DispatchQueue.main.asyncAfter`)를 추가.
- `LiveRoomViewModel.setListener()`에서 현재 사용자가 해제 대상 스탭이었던 경우(`wasManager`)에 `setManagerMessage()`를 전파해, 실제 해제 완료 이후 전체 클라이언트가 `getRoomInfo()`를 재호출하도록 보강.
- 실행 명령 및 결과:
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build``** BUILD SUCCEEDED **`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build``** BUILD SUCCEEDED **`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test``Scheme SodaLive is not currently configured for the test action.`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test``Scheme SodaLive-dev is not currently configured for the test action.`
- `lsp_diagnostics(LiveRoomViewModel.swift)``No such module 'Moya'` (로컬 SourceKit 모듈 해석 환경 이슈로 확인됨)
- 수동 QA 시나리오(디바이스/시뮬레이터):
1. 방장이 스피커/리스너를 스탭으로 지정한다.
2. `LiveRoomProfilesDialogView`에서 스탭 섹션에 즉시 반영되는지 확인한다.
3. 동일 사용자를 스탭 해제한다.
4. 다이얼로그를 닫지 않은 상태에서도 스탭 섹션에서 제거되는지 확인한다.

View File

@@ -0,0 +1,48 @@
# 20260330 라이브 캡쳐/녹화 가능 여부 설정 추가
## 작업 체크리스트
- [x] 라이브 정보 응답 모델에 `isCaptureRecordingAvailable` 필드 추가 및 매핑 확인
- [x] `LiveRoomViewV2` 캡쳐/녹화 보호 조건에 라이브 설정값 반영
- [x] 캡쳐/녹화 불가 라이브에서 방장/스탭 예외 허용 유지
- [x] 라이브 생성 경로에만 설정값 전송되도록 반영
- [x] 라이브 수정(편집) 경로에서 해당 설정 변경 불가 상태 유지 확인
- [x] 진단/빌드/테스트/수동 QA 수행
## 수용 기준 (Acceptance Criteria)
- [x] `GetRoomInfoResponse`(또는 동등 라이브 정보 모델)에 `isCaptureRecordingAvailable`가 존재한다.
- [x] 라이브 설정값이 `true`면 일반 참여자도 캡쳐/녹화 보호가 비활성화된다.
- [x] 라이브 설정값이 `false`면 일반 참여자는 기존 캡쳐/녹화 보호가 유지된다.
- [x] 라이브 설정값이 `false`여도 방장/스탭은 캡쳐/녹화 보호 대상이 아니다.
- [x] 설정값은 라이브 생성 요청에서만 설정 가능하고, 라이브 수정 요청에서는 변경되지 않는다.
- [x] 변경 파일 `lsp_diagnostics`를 수행했고 `SodaLive`/`SodaLive-dev` Debug build가 성공한다.
## 검증 기록
### 1차 검증 (2026-03-30)
- 무엇/왜/어떻게:
- 무엇: 라이브 정보/생성 요청에 `isCaptureRecordingAvailable`를 추가하고, `LiveRoomViewV2`의 캡쳐 보호 조건을 라이브 설정값 + 방장/스탭 예외로 갱신.
- 왜: 캡쳐/녹화 가능 여부를 라이브 생성 시점에만 제어하면서, 비허용 라이브에서도 운영 권한(방장/스탭) 예외를 유지하기 위해.
- 어떻게: 모델(`GetRoomInfoResponse`, `CreateLiveRoomRequest`, `GetRecentRoomInfoResponse`), 생성 UI/ViewModel(`LiveRoomCreateView`, `LiveRoomCreateViewModel`), 보호 로직(`LiveRoomViewV2`)을 최소 수정으로 연결.
- 실행 명령:
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Create/CreateLiveRoomRequest.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Create/GetRecentRoomInfoResponse.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Create/LiveRoomCreateViewModel.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/Create/LiveRoomCreateView.swift, severity: all)`
- `lsp_diagnostics(filePath: SodaLive/Sources/I18n/I18n.swift, severity: all)`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" -configuration Debug build && xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build`
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
- `grep("isCaptureRecordingAvailable", include: *.swift, path: SodaLive/Sources/Live/Room)`
- `grep("captureRecordingSetting|captureRecordingAllowed|captureRecordingNotAllowed", include: *.swift, path: SodaLive/Sources)`
- `grep("isCaptureRecordingAvailable", include: *.swift, path: SodaLive/Sources/Live/Room/Edit)`
- 결과:
- `lsp_diagnostics`:
- `LiveRoomViewV2.swift`, `GetRecentRoomInfoResponse.swift`, `I18n.swift``No diagnostics found`
- 일부 파일(`CreateLiveRoomRequest.swift`, `LiveRoomCreateViewModel.swift`, `LiveRoomCreateView.swift`, `GetRoomInfoResponse.swift`)은 SourceKit 모듈/심볼 해석 한계(`No such module`, `Cannot find type ... in scope`)가 보고됨
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
- 테스트: `Scheme SodaLive is not currently configured for the test action.`, `Scheme SodaLive-dev is not currently configured for the test action.`
- 수동 QA(코드 경로):
- 생성 UI에 캡쳐/녹화 허용 토글 추가 확인 (`LiveRoomCreateView`)
- 생성 요청에만 `isCaptureRecordingAvailable` 전송 확인 (`CreateLiveRoomRequest`, `LiveRoomCreateViewModel`)
- 편집 경로에 해당 필드 미존재 확인 (`Live/Room/Edit` grep 결과 없음)
- 라이브룸 보호 분기 확인: `isCaptureRecordingAvailable == true`면 보호 비활성화, `false`면 방장/스탭 예외 외 참여자 보호 유지 (`LiveRoomViewV2`)