fix(live-room): 방장 캡쳐 보호를 해제하고 비방장 보호를 유지한다
This commit is contained in:
@@ -61,9 +61,21 @@ 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 shouldEnforceScreenCaptureProtection: Bool {
|
||||
!isCurrentUserHost
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScreenCaptureSecureContainer {
|
||||
ScreenCaptureSecureContainer(isSecureModeEnabled: shouldEnforceScreenCaptureProtection) {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
|
||||
@@ -533,8 +545,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 +939,10 @@ 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.isChatFrozenForCurrentUser) { isFrozen in
|
||||
if isFrozen {
|
||||
@@ -1107,18 +1121,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 +1148,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 +1169,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 +1188,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 +1235,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 +1282,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 +1328,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
|
||||
|
||||
38
docs/20260328_방장캡쳐녹화허용.md
Normal file
38
docs/20260328_방장캡쳐녹화허용.md
Normal 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는 불가하여, 실기기 최종 확인이 추가로 필요.
|
||||
Reference in New Issue
Block a user