fix(live-room): 캡쳐 보호 음소거 동기화
This commit is contained in:
@@ -523,6 +523,14 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
role = .LISTENER
|
role = .LISTENER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isSpeakerMute {
|
||||||
|
agora.speakerMute(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMute {
|
||||||
|
agora.mute(true)
|
||||||
|
}
|
||||||
|
|
||||||
DEBUG_LOG("agoraConnectSuccess")
|
DEBUG_LOG("agoraConnectSuccess")
|
||||||
|
|
||||||
@@ -673,22 +681,33 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
|||||||
.store(in: &subscription)
|
.store(in: &subscription)
|
||||||
}
|
}
|
||||||
|
|
||||||
func toggleMute() {
|
func setMute(_ isMuted: Bool) {
|
||||||
isMute.toggle()
|
isMute = isMuted
|
||||||
agora.mute(isMute)
|
agora.mute(isMuted)
|
||||||
|
|
||||||
if isMute {
|
let userId = UInt(UserDefaults.int(forKey: .userId))
|
||||||
muteSpeakers.append(UInt(UserDefaults.int(forKey: .userId)))
|
if isMuted {
|
||||||
|
if !muteSpeakers.contains(userId) {
|
||||||
|
muteSpeakers.append(userId)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if let index = muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) {
|
if let index = muteSpeakers.firstIndex(of: userId) {
|
||||||
muteSpeakers.remove(at: index)
|
muteSpeakers.remove(at: index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func toggleMute() {
|
||||||
|
setMute(!isMute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSpeakerMute(_ isMuted: Bool) {
|
||||||
|
isSpeakerMute = isMuted
|
||||||
|
agora.speakerMute(isMuted)
|
||||||
|
}
|
||||||
|
|
||||||
func toggleSpeakerMute() {
|
func toggleSpeakerMute() {
|
||||||
isSpeakerMute.toggle()
|
setSpeakerMute(!isSpeakerMute)
|
||||||
agora.speakerMute(isSpeakerMute)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendMessage(chatMessage: String, onSuccess: @escaping () -> Void) {
|
func sendMessage(chatMessage: String, onSuccess: @escaping () -> Void) {
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ struct LiveRoomViewV2: View {
|
|||||||
@State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil
|
@State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil
|
||||||
@State private var selectedChatForDelete: LiveRoomNormalChat? = nil
|
@State private var selectedChatForDelete: LiveRoomNormalChat? = nil
|
||||||
@State private var isShowChatDeleteDialog: Bool = false
|
@State private var isShowChatDeleteDialog: Bool = false
|
||||||
|
// 화면 캡쳐/녹화 보호 상태 및 강제 음소거 복원 여부
|
||||||
|
@State private var isScreenCaptureProtected: Bool = UIScreen.main.isCaptured
|
||||||
|
@State private var shouldRestoreSpeakerMuteAfterCapture: Bool = false
|
||||||
|
@State private var shouldRestoreMicMuteAfterCapture: Bool = false
|
||||||
let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect()
|
let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
private var appliedKeyboardHeight: CGFloat {
|
private var appliedKeyboardHeight: CGFloat {
|
||||||
@@ -59,7 +63,8 @@ struct LiveRoomViewV2: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ScreenCaptureSecureContainer {
|
||||||
|
ZStack {
|
||||||
Color.black.edgesIgnoringSafeArea(.all)
|
Color.black.edgesIgnoringSafeArea(.all)
|
||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -527,9 +532,11 @@ struct LiveRoomViewV2: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
UIApplication.shared.isIdleTimerDisabled = true
|
UIApplication.shared.isIdleTimerDisabled = true
|
||||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||||
|
viewModel.initAgoraEngine()
|
||||||
|
// 진입 시 현재 캡쳐 상태를 즉시 동기화해 첫 프레임부터 보호 상태를 반영
|
||||||
|
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
|
||||||
|
|
||||||
viewModel.getMemberCan()
|
viewModel.getMemberCan()
|
||||||
viewModel.initAgoraEngine()
|
|
||||||
viewModel.getRoomInfo()
|
viewModel.getRoomInfo()
|
||||||
viewModel.getBlockedMemberIdList()
|
viewModel.getBlockedMemberIdList()
|
||||||
|
|
||||||
@@ -544,6 +551,8 @@ struct LiveRoomViewV2: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
UIApplication.shared.isIdleTimerDisabled = false
|
UIApplication.shared.isIdleTimerDisabled = false
|
||||||
NotificationCenter.default.removeObserver(self)
|
NotificationCenter.default.removeObserver(self)
|
||||||
|
// 화면 이탈 시 캡쳐로 인해 강제 변경한 음소거 상태를 원복
|
||||||
|
releaseForcedCaptureMute()
|
||||||
viewModel.stopV2VTranslationIfJoined()
|
viewModel.stopV2VTranslationIfJoined()
|
||||||
viewModel.stopPeriodicPlaybackValidation()
|
viewModel.stopPeriodicPlaybackValidation()
|
||||||
}
|
}
|
||||||
@@ -845,46 +854,54 @@ struct LiveRoomViewV2: View {
|
|||||||
if isImageLoading {
|
if isImageLoading {
|
||||||
LoadingView()
|
LoadingView()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isScreenCaptureProtected {
|
||||||
|
Color.black
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.allowsHitTesting(true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.overlay(alignment: .center) {
|
.overlay(alignment: .center) {
|
||||||
ZStack {
|
if !isScreenCaptureProtected {
|
||||||
// 로컬(롱프레스 중) 물 채우기 하트
|
|
||||||
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
|
|
||||||
.frame(width: 210, height: 210)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
.opacity(showWaterHeart ? 1 : 0)
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
|
|
||||||
|
|
||||||
// 원격 수신 시(또는 공통 트리거) 물 채우기 하트 - 0→1 스케일 업(1.0~1.5s 랜덤)
|
|
||||||
WaterHeartView(progress: viewModel.remoteWaterProgress,
|
|
||||||
show: viewModel.isShowRemoteBigHeart,
|
|
||||||
phase: viewModel.remoteWavePhase)
|
|
||||||
.frame(width: 210, height: 210)
|
|
||||||
.scaleEffect(viewModel.remoteHeartScale)
|
|
||||||
.allowsHitTesting(false)
|
|
||||||
// 롱프레스 로컬 연출 중에는 원격 하트를 숨겨 중복 방지
|
|
||||||
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
|
|
||||||
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
|
|
||||||
|
|
||||||
// 폭발 파편 (작은 하트, #ff959a)
|
|
||||||
ZStack {
|
ZStack {
|
||||||
ForEach(viewModel.bigHeartParticles) { p in
|
// 로컬(롱프레스 중) 물 채우기 하트
|
||||||
HeartShape()
|
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
|
||||||
.fill(Color(hex: "ff959a"))
|
.frame(width: 210, height: 210)
|
||||||
.frame(width: p.size * p.scale, height: p.size * p.scale)
|
.allowsHitTesting(false)
|
||||||
.rotationEffect(.degrees(p.rotation))
|
.opacity(showWaterHeart ? 1 : 0)
|
||||||
.offset(x: p.x, y: p.y)
|
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
|
||||||
.opacity(p.opacity)
|
|
||||||
.allowsHitTesting(false)
|
// 원격 수신 시(또는 공통 트리거) 물 채우기 하트 - 0→1 스케일 업(1.0~1.5s 랜덤)
|
||||||
|
WaterHeartView(progress: viewModel.remoteWaterProgress,
|
||||||
|
show: viewModel.isShowRemoteBigHeart,
|
||||||
|
phase: viewModel.remoteWavePhase)
|
||||||
|
.frame(width: 210, height: 210)
|
||||||
|
.scaleEffect(viewModel.remoteHeartScale)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
// 롱프레스 로컬 연출 중에는 원격 하트를 숨겨 중복 방지
|
||||||
|
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
|
||||||
|
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
|
||||||
|
|
||||||
|
// 폭발 파편 (작은 하트, #ff959a)
|
||||||
|
ZStack {
|
||||||
|
ForEach(viewModel.bigHeartParticles) { p in
|
||||||
|
HeartShape()
|
||||||
|
.fill(Color(hex: "ff959a"))
|
||||||
|
.frame(width: p.size * p.scale, height: p.size * p.scale)
|
||||||
|
.rotationEffect(.degrees(p.rotation))
|
||||||
|
.offset(x: p.x, y: p.y)
|
||||||
|
.opacity(p.opacity)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// drawingGroup은 레이어의 경계(Rect) 밖을 클립하므로, 전체 화면 크기로 확장해 클리핑 방지
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.drawingGroup(opaque: false, colorMode: .linear)
|
||||||
}
|
}
|
||||||
// drawingGroup은 레이어의 경계(Rect) 밖을 클립하므로, 전체 화면 크기로 확장해 클리핑 방지
|
// 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
|
||||||
.drawingGroup(opaque: false, colorMode: .linear)
|
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
|
||||||
}
|
}
|
||||||
// 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동
|
|
||||||
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
|
|
||||||
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
|
|
||||||
}
|
}
|
||||||
.onReceive(heartWaveTimer) { _ in
|
.onReceive(heartWaveTimer) { _ in
|
||||||
guard isLongPressingHeart else { return }
|
guard isLongPressingHeart else { return }
|
||||||
@@ -909,11 +926,26 @@ struct LiveRoomViewV2: View {
|
|||||||
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
|
||||||
viewModel.quitRoom()
|
viewModel.quitRoom()
|
||||||
}
|
}
|
||||||
|
// 시스템 캡쳐 상태 변경(녹화 시작/종료 등)에 맞춰 보호 로직 갱신
|
||||||
|
.onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in
|
||||||
|
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
|
||||||
|
}
|
||||||
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
|
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
|
||||||
if isFrozen {
|
if isFrozen {
|
||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: viewModel.role) { role in
|
||||||
|
guard isScreenCaptureProtected,
|
||||||
|
role == .SPEAKER,
|
||||||
|
!viewModel.isMute else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캡쳐 중 리스너→스피커 전환 시 마이크가 즉시 켜지지 않도록 강제 음소거
|
||||||
|
viewModel.setMute(true)
|
||||||
|
shouldRestoreMicMuteAfterCapture = true
|
||||||
|
}
|
||||||
.ignoresSafeArea(.keyboard)
|
.ignoresSafeArea(.keyboard)
|
||||||
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
|
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
|
||||||
.sheet(
|
.sheet(
|
||||||
@@ -1022,6 +1054,7 @@ struct LiveRoomViewV2: View {
|
|||||||
hideKeyboard()
|
hideKeyboard()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {
|
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {
|
||||||
@@ -1072,7 +1105,205 @@ struct LiveRoomViewV2: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 스크린샷/화면 녹화 노출 보호를 위해 SwiftUI 콘텐츠를 보안 컨테이너에 탑재
|
||||||
|
private struct ScreenCaptureSecureContainer<Content: View>: UIViewControllerRepresentable {
|
||||||
|
let content: Content
|
||||||
|
|
||||||
|
init(@ViewBuilder content: () -> Content) {
|
||||||
|
self.content = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> ScreenCaptureSecureHostingController<Content> {
|
||||||
|
ScreenCaptureSecureHostingController(rootView: content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: ScreenCaptureSecureHostingController<Content>, context: Context) {
|
||||||
|
uiViewController.update(rootView: content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwiftUI 뷰를 UIKit 보안 뷰 계층에 삽입하기 위한 호스팅 컨트롤러
|
||||||
|
private final class ScreenCaptureSecureHostingController<Content: View>: UIViewController {
|
||||||
|
private let secureContainerView = ScreenCaptureSecureView()
|
||||||
|
private let hostingController: UIHostingController<Content>
|
||||||
|
|
||||||
|
init(rootView: Content) {
|
||||||
|
hostingController = UIHostingController(rootView: rootView)
|
||||||
|
super.init(nibName: nil, bundle: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func loadView() {
|
||||||
|
view = secureContainerView
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidLoad() {
|
||||||
|
super.viewDidLoad()
|
||||||
|
|
||||||
|
hostingController.view.backgroundColor = .clear
|
||||||
|
addChild(hostingController)
|
||||||
|
secureContainerView.embed(contentView: hostingController.view)
|
||||||
|
hostingController.didMove(toParent: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(rootView: Content) {
|
||||||
|
hostingController.rootView = rootView
|
||||||
|
secureContainerView.embed(contentView: hostingController.view)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSecureTextEntry 기반 보안 렌더링 계층을 구성하는 컨테이너 뷰
|
||||||
|
private final class ScreenCaptureSecureView: UIView {
|
||||||
|
private let secureTextField = ScreenCaptureSecureTextField()
|
||||||
|
private weak var secureContentView: UIView?
|
||||||
|
private let failClosedOverlayView = UIView()
|
||||||
|
private var didLogFailClosedActivation = false
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setup() {
|
||||||
|
backgroundColor = .clear
|
||||||
|
|
||||||
|
// 보안 텍스트필드를 화면 전체에 배치해 하위 콘텐츠를 보안 계층으로 렌더링
|
||||||
|
secureTextField.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
secureTextField.isSecureTextEntry = true
|
||||||
|
secureTextField.backgroundColor = .clear
|
||||||
|
secureTextField.textColor = .clear
|
||||||
|
secureTextField.tintColor = .clear
|
||||||
|
|
||||||
|
addSubview(secureTextField)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
secureTextField.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
secureTextField.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
secureTextField.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
secureTextField.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
failClosedOverlayView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
failClosedOverlayView.backgroundColor = .black
|
||||||
|
failClosedOverlayView.isUserInteractionEnabled = true
|
||||||
|
failClosedOverlayView.isHidden = true
|
||||||
|
addSubview(failClosedOverlayView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
failClosedOverlayView.topAnchor.constraint(equalTo: topAnchor),
|
||||||
|
failClosedOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||||
|
failClosedOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||||
|
failClosedOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||||
|
])
|
||||||
|
|
||||||
|
// 텍스트필드 내부 보안 렌더링 뷰를 실제 콘텐츠 탑재 대상으로 사용
|
||||||
|
secureContentView = resolveSecureContentView()
|
||||||
|
updateFailClosedState(isActive: secureContentView == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func embed(contentView: UIView) {
|
||||||
|
if secureContentView == nil {
|
||||||
|
secureContentView = resolveSecureContentView()
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let secureContentView else {
|
||||||
|
contentView.removeFromSuperview()
|
||||||
|
updateFailClosedState(isActive: true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFailClosedState(isActive: false)
|
||||||
|
|
||||||
|
guard contentView.superview !== secureContentView else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 부모에서 분리 후 보안 콘텐츠 뷰에 다시 부착
|
||||||
|
contentView.removeFromSuperview()
|
||||||
|
contentView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
secureContentView.addSubview(contentView)
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
contentView.topAnchor.constraint(equalTo: secureContentView.topAnchor),
|
||||||
|
contentView.leadingAnchor.constraint(equalTo: secureContentView.leadingAnchor),
|
||||||
|
contentView.trailingAnchor.constraint(equalTo: secureContentView.trailingAnchor),
|
||||||
|
contentView.bottomAnchor.constraint(equalTo: secureContentView.bottomAnchor)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveSecureContentView() -> UIView? {
|
||||||
|
secureTextField.subviews.first {
|
||||||
|
let className = NSStringFromClass(type(of: $0))
|
||||||
|
return className.contains("CanvasView")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateFailClosedState(isActive: Bool) {
|
||||||
|
failClosedOverlayView.isHidden = !isActive
|
||||||
|
|
||||||
|
guard isActive, !didLogFailClosedActivation else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
didLogFailClosedActivation = true
|
||||||
|
ERROR_LOG("[ScreenCaptureSecureView] secure canvas lookup failed. Activating fail-closed overlay.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 키보드 포커스를 차단해 보안 텍스트필드가 입력 UI로 동작하지 않도록 제한
|
||||||
|
private final class ScreenCaptureSecureTextField: UITextField {
|
||||||
|
override var canBecomeFirstResponder: Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
override func becomeFirstResponder() -> Bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private extension LiveRoomViewV2 {
|
private extension LiveRoomViewV2 {
|
||||||
|
func applyScreenCaptureProtection(isCaptured: Bool) {
|
||||||
|
// 시스템 캡쳐 상태를 로컬 UI 상태와 동기화
|
||||||
|
isScreenCaptureProtected = isCaptured
|
||||||
|
|
||||||
|
if isCaptured {
|
||||||
|
// 원래 음소거가 아니었던 경우에만 강제 음소거 후 복원 플래그 저장
|
||||||
|
if !viewModel.isSpeakerMute {
|
||||||
|
viewModel.setSpeakerMute(true)
|
||||||
|
shouldRestoreSpeakerMuteAfterCapture = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.isMute {
|
||||||
|
viewModel.setMute(true)
|
||||||
|
shouldRestoreMicMuteAfterCapture = true
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
releaseForcedCaptureMute()
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseForcedCaptureMute() {
|
||||||
|
// 캡쳐 보호로 인해 강제 변경한 상태만 선택적으로 원복
|
||||||
|
if shouldRestoreSpeakerMuteAfterCapture {
|
||||||
|
if viewModel.isSpeakerMute {
|
||||||
|
viewModel.setSpeakerMute(false)
|
||||||
|
}
|
||||||
|
shouldRestoreSpeakerMuteAfterCapture = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldRestoreMicMuteAfterCapture {
|
||||||
|
if viewModel.isMute {
|
||||||
|
viewModel.setMute(false)
|
||||||
|
}
|
||||||
|
shouldRestoreMicMuteAfterCapture = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func guestFollowButtonType(liveRoomInfo: GetRoomInfoResponse) -> FollowButtonImageType {
|
func guestFollowButtonType(liveRoomInfo: GetRoomInfoResponse) -> FollowButtonImageType {
|
||||||
if liveRoomInfo.isFollowing {
|
if liveRoomInfo.isFollowing {
|
||||||
return guestFollowButtonTypeOverride ?? .following
|
return guestFollowButtonTypeOverride ?? .following
|
||||||
|
|||||||
188
docs/20260324_라이브룸캡쳐녹화보안처리.md
Normal file
188
docs/20260324_라이브룸캡쳐녹화보안처리.md
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
# 20260324 라이브룸 캡쳐/녹화 보안 및 오디오 차단 통합 계획
|
||||||
|
|
||||||
|
## 작업 체크리스트
|
||||||
|
- [x] `LiveRoomViewV2`의 캡쳐/녹화 감지 및 기존 음소거 제어 포인트 확인
|
||||||
|
- [x] 캡쳐/녹화 시작 시 화면 검정 오버레이 적용
|
||||||
|
- [x] 캡쳐/녹화 시작 시 음소거 강제 적용
|
||||||
|
- [x] 종료 시 화면/음소거 상태 복원 로직 점검
|
||||||
|
- [x] 진단/빌드/테스트 검증 수행 및 기록
|
||||||
|
|
||||||
|
## 수용 기준 (Acceptance Criteria)
|
||||||
|
- [x] `UIScreen.main.isCaptured == true` 상태에서 라이브룸 주요 콘텐츠 위에 검정 화면이 표시된다.
|
||||||
|
- [x] 캡쳐/녹화 상태 진입 시 스피커 출력이 음소거된다.
|
||||||
|
- [x] 내가 스피커 역할일 경우, 캡쳐/녹화 상태 진입 시 마이크도 음소거된다.
|
||||||
|
- [x] 캡쳐/녹화 상태 해제 시 사용자의 기존 음소거 상태를 유지/복원한다.
|
||||||
|
|
||||||
|
## 후속 작업 체크리스트 (화면 캡쳐 미차단)
|
||||||
|
- [x] 화면 녹화와 화면 캡쳐의 동작 차이를 iOS 시스템 제약 관점에서 확인
|
||||||
|
- [x] 코드베이스 내 캡쳐 차단/보호 패턴 유무 조사
|
||||||
|
- [x] 가능한 최소 수정안 적용 (캡쳐 시 검정 처리 보강)
|
||||||
|
- [x] LSP/빌드/테스트 및 수동 QA 결과 기록
|
||||||
|
|
||||||
|
## 후속 수용 기준 (Pass/Fail)
|
||||||
|
- [x] 원인: 화면 캡쳐 시 기존 로직이 검정 화면을 만들지 못한 이유를 코드와 플랫폼 제약으로 설명 가능
|
||||||
|
- [x] 조치: 캡쳐 시점에도 검정 처리(또는 동등한 보호)가 적용되는 코드가 반영됨
|
||||||
|
- [x] 안정성: 수정 파일 `lsp_diagnostics` 무오류
|
||||||
|
- [x] 회귀: `SodaLive`, `SodaLive-dev` Debug build 성공
|
||||||
|
|
||||||
|
## 검증 기록
|
||||||
|
### 1차 검증 (2026-03-24)
|
||||||
|
- 무엇/왜/어떻게:
|
||||||
|
- 무엇: `LiveRoomViewV2`에 캡쳐/녹화 감지, 검정 오버레이, 음소거 강제/복원 로직을 추가하고 회귀를 확인.
|
||||||
|
- 왜: 요청사항(검정 캡쳐 + 음소거) 충족과 기존 동작 안정성 보장.
|
||||||
|
- 어떻게: LSP 진단, 스킴 빌드, 테스트 액션 실행 결과를 기록하고 수동 확인 가능 범위를 점검.
|
||||||
|
- 실행 명령:
|
||||||
|
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.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`
|
||||||
|
- 결과:
|
||||||
|
- `lsp_diagnostics`: `No diagnostics found`
|
||||||
|
- `SodaLive` 빌드: `** BUILD SUCCEEDED **` (병렬 빌드 시 1회 `build.db locked` 발생 후 단독 재실행으로 성공)
|
||||||
|
- `SodaLive-dev` 빌드: `** BUILD SUCCEEDED **`
|
||||||
|
- `SodaLive` 테스트: `Scheme SodaLive is not currently configured for the test action.`
|
||||||
|
- `SodaLive-dev` 테스트: `Scheme SodaLive-dev is not currently configured for the test action.`
|
||||||
|
- 수동 QA: 현재 CLI/헤드리스 환경에서는 실제 기기/시뮬레이터에서 화면 캡쳐·녹화 시작 이벤트를 직접 조작하는 E2E 검증이 제한되어, 코드 경로(`UIScreen.capturedDidChangeNotification` 수신 시 검정 오버레이 + 음소거 적용, 해제 시 복원)까지 확인.
|
||||||
|
|
||||||
|
### 2차 검증 (2026-03-24) — 화면 캡쳐 미차단 후속 대응
|
||||||
|
- 무엇/왜/어떻게:
|
||||||
|
- 무엇: 화면 캡쳐가 그대로 저장되는 원인을 확인하고, `LiveRoomViewV2` 전체를 보안 컨테이너(`isSecureTextEntry` 기반)로 감싸 캡쳐 보호를 보강.
|
||||||
|
- 왜: `UIScreen.main.isCaptured`/`capturedDidChangeNotification`은 녹화·미러링 상태 변화에는 반응하지만, 스크린샷은 사후 알림(`userDidTakeScreenshotNotification`)만 가능해 기존 오버레이 방식으로는 캡쳐본을 검정으로 바꾸지 못하기 때문.
|
||||||
|
- 어떻게: 내부 패턴 탐색(explore) + iOS 공식 동작 조사(librarian)로 원인을 확정한 뒤, `ScreenCaptureSecureContainer`를 `LiveRoomViewV2` body 루트에 적용.
|
||||||
|
- 실행 명령:
|
||||||
|
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.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`
|
||||||
|
- 결과:
|
||||||
|
- `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: 현재 CLI 환경에서는 라이브룸 실화면에서 직접 스크린샷/녹화를 트리거하는 E2E 자동화가 불가하여, 기기/시뮬레이터에서 최종 캡쳐 결과 확인이 필요함.
|
||||||
|
- 참고: `isSecureTextEntry` 기반 전체 뷰 보호는 실무적으로 사용되는 우회 방식이며, iOS 버전별 동작 차이가 있을 수 있어 실제 단말 검증을 필수로 유지.
|
||||||
|
|
||||||
|
## 후속 작업 체크리스트 (확정 이슈/보강 통합)
|
||||||
|
- [x] `LiveRoomViewV2` 캡쳐 보호 상태에서 `.overlay` 이펙트 렌더링 차단
|
||||||
|
- [x] 캡쳐 보호 오버레이가 터치를 통과시키지 않도록 입력 차단 유지
|
||||||
|
- [x] 캡쳐 해제 시 마이크 복원 로직의 role 의존 조건 제거/보완
|
||||||
|
- [x] `ScreenCaptureSecureView.setup()` secure 컨테이너 탐색 흐름 점검
|
||||||
|
- [x] secure 컨테이너 탐색 실패 시 fail-open(`UITextField` 직접 사용) 제거 및 fail-closed 처리 적용
|
||||||
|
- [x] 관련 진단/빌드/테스트 및 수동 QA 결과 누적 기록
|
||||||
|
|
||||||
|
## 후속 수용 기준 (확정 이슈/보강 Pass/Fail)
|
||||||
|
- [x] Pass: `isScreenCaptureProtected == true`일 때 검정 보호 레이어 최상단 노출 상태에서 하트/파티클 오버레이가 렌더링되지 않는다.
|
||||||
|
- QA: `.overlay(alignment: .center)` 내부를 `if !isScreenCaptureProtected`로 가드해 보호 상태에서 오버레이 뷰 트리를 생성하지 않음을 코드 레벨로 확인.
|
||||||
|
- [x] Pass: `isScreenCaptureProtected == false`일 때 기존 하트/파티클 오버레이 동작이 유지된다.
|
||||||
|
- QA: 보호 해제 상태에서 기존 `WaterHeartView`, `bigHeartParticles` 렌더 경로가 동일하게 남아 있음을 확인.
|
||||||
|
- [x] Pass: `isScreenCaptureProtected == true`일 때 하위 UI 상호작용이 차단된다.
|
||||||
|
- QA: 오버레이에 `.allowsHitTesting(true)`가 적용되어 입력을 오버레이가 수신하는지 확인.
|
||||||
|
- [x] Pass: 캡쳐 해제 시 role 상태와 무관하게 강제 마이크 mute 복원이 누락되지 않는다.
|
||||||
|
- QA: `releaseForcedCaptureMute()`에서 `shouldRestoreMicMuteAfterCapture` 경로가 role 조건 없이 동작하는지 확인.
|
||||||
|
- [x] Pass: secure 컨테이너 탐색 실패 시 일반 계층으로 폴백하지 않고 fail-closed(검정 오버레이 유지)로 동작한다.
|
||||||
|
- QA: `secureTextField.subviews.first ?? secureTextField` 폴백 제거 및 secure canvas 탐색 실패 시 콘텐츠 미탑재/오버레이 활성화를 코드 레벨로 확인.
|
||||||
|
|
||||||
|
### 3차 검증 (2026-03-24) — 캡쳐 보호 오버레이 렌더 차단
|
||||||
|
- 무엇/왜/어떻게:
|
||||||
|
- 무엇: 캡쳐 보호 상태에서도 오버레이 하트/파티클이 노출될 수 있는 경로를 차단.
|
||||||
|
- 왜: 보호 상태의 화면 노출 가능성을 제거해 보안/정합성 리스크를 낮추기 위해.
|
||||||
|
- 어떻게: `LiveRoomViewV2.swift`의 `.overlay` 렌더링을 `isScreenCaptureProtected`와 연동해 보호 시 비활성화하고, 정적 진단/빌드/테스트 액션을 검증.
|
||||||
|
- 실행 명령:
|
||||||
|
- `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`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
|
||||||
|
- 결과:
|
||||||
|
- `lsp_diagnostics`: `No diagnostics found`
|
||||||
|
- 빌드: `SodaLive`, `SodaLive-dev` 모두 `** BUILD SUCCEEDED **`
|
||||||
|
- 테스트: 두 스킴 모두 `is not currently configured for the test action`로 테스트 액션 미구성 확인
|
||||||
|
|
||||||
|
### 4차 검증 (2026-03-24) — 캡쳐 보호 확정 이슈 패치
|
||||||
|
- 무엇/왜/어떻게:
|
||||||
|
- 무엇: 캡쳐 보호 중 입력 차단과 마이크 복원 누락 이슈를 패치.
|
||||||
|
- 왜: 확정된 중간 심각도 안정성 이슈를 제거하기 위해.
|
||||||
|
- 어떻게: 코드 수정 후 LSP 진단, 스킴 빌드, 테스트 액션, 수동 QA 가능 범위를 기록.
|
||||||
|
- 실행 명령:
|
||||||
|
- `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`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
|
||||||
|
- `Read(SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.swift:850~874, 1248~1271)`
|
||||||
|
- 결과:
|
||||||
|
- `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(가능 범위):
|
||||||
|
- 오버레이 경로 확인: `if isScreenCaptureProtected { Color.black ... .allowsHitTesting(true) }`
|
||||||
|
- 복원 경로 확인: `if shouldRestoreMicMuteAfterCapture { if viewModel.isMute { viewModel.toggleMute() } ... }`
|
||||||
|
- 현재 CLI/헤드리스 환경에서는 실제 라이브룸 진입 후 캡쳐·녹화 이벤트를 조작하는 디바이스 E2E 검증이 제한됨.
|
||||||
|
|
||||||
|
### 5차 검증 (2026-03-24) — secure 컨테이너 폴백 fail-closed 보강
|
||||||
|
- 무엇/왜/어떻게:
|
||||||
|
- 무엇: `ScreenCaptureSecureView`에서 `secureTextField.subviews.first ?? secureTextField` 폴백을 제거하고, secure canvas 식별 실패 시 검정 fail-closed 오버레이를 유지하도록 변경.
|
||||||
|
- 왜: secure 렌더링 컨테이너 탐색 실패 시 일반 계층으로 콘텐츠가 붙어 캡쳐 보호가 무력화될 수 있는 fail-open 동작을 차단하기 위해.
|
||||||
|
- 어떻게: `CanvasView` 클래스명 기반 secure 컨테이너 탐색 + 실패 시 `ERROR_LOG` 1회 기록 및 콘텐츠 미탑재 처리.
|
||||||
|
- 실행 명령:
|
||||||
|
- `lsp_diagnostics(filePath: SodaLive/Sources/Live/Room/V2/LiveRoomViewV2.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`
|
||||||
|
- 결과:
|
||||||
|
- `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.`
|
||||||
|
|
||||||
|
## 통합 이력 (중복 문서 병합)
|
||||||
|
- 본 문서는 아래 연속 수정 문서를 하나로 통합한 기준 문서다.
|
||||||
|
- `docs/20260324_캡쳐보호초기진입음소거보완.md`
|
||||||
|
- `docs/20260324_라이브녹화오디오차단수정.md`
|
||||||
|
|
||||||
|
## 통합 작업 체크리스트 (추가)
|
||||||
|
- [x] `LiveRoomViewModel`에 idempotent 음소거 setter(`setMute`, `setSpeakerMute`) 추가
|
||||||
|
- [x] 캡쳐 보호 로직에서 toggle 호출을 setter 호출로 전환
|
||||||
|
- [x] 라이브룸 진입 시 Agora 엔진 초기화와 캡쳐 보호 적용 순서 보강
|
||||||
|
- [x] 라이브 캡쳐/녹화 시 오디오 녹음 경로 원인 분석 (코드+외부 문서)
|
||||||
|
- [x] 기존 캡쳐 보호 음소거 로직의 연결 타이밍/상태 전이 검증
|
||||||
|
- [x] 최소 수정으로 오디오 차단 누락 경로 패치
|
||||||
|
|
||||||
|
## 통합 수용 기준 (추가)
|
||||||
|
- [x] 캡쳐가 이미 활성화된 상태로 라이브룸 진입해도 원격 오디오 음소거가 누락되지 않는다.
|
||||||
|
- [x] 캡쳐 보호 진입/해제 시 마이크·스피커 음소거 상태가 토글 누적 없이 일관되게 유지/복원된다.
|
||||||
|
- [x] 영상 보호(검정/보안 컨테이너) 상태에서 라이브 오디오가 녹화에 남지 않는다.
|
||||||
|
- [x] 초기 진입/연결 완료/역할 전환 시점 모두에서 캡쳐 보호 음소거가 일관 적용된다.
|
||||||
|
- [x] 변경 파일 `lsp_diagnostics` 무오류 및 `SodaLive`/`SodaLive-dev` Debug build 성공.
|
||||||
|
|
||||||
|
## 통합 검증 기록 (추가)
|
||||||
|
### 6차 검증 (2026-03-24) — 초기 진입 캡쳐 보호 음소거 보완
|
||||||
|
- 무엇/왜/어떻게:
|
||||||
|
- 무엇: `LiveRoomViewModel`에 상태 기반 음소거 setter를 추가하고, `LiveRoomViewV2` 캡쳐 보호 경로를 toggle 호출에서 setter 호출로 전환.
|
||||||
|
- 왜: 캡쳐가 이미 켜진 상태로 화면 진입 시 Agora 엔진 초기화 타이밍 때문에 스피커 강제 음소거가 누락될 수 있는 경로를 제거하기 위해.
|
||||||
|
- 어떻게: `onAppear`에서 `initAgoraEngine()` 호출을 선행시키고, 캡쳐 보호 진입/복원 및 role 변경 경로를 idempotent setter 기반으로 정리.
|
||||||
|
- 실행 명령:
|
||||||
|
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift, severity: all)`
|
||||||
|
- `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`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
|
||||||
|
- 결과:
|
||||||
|
- `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: 현재 CLI/헤드리스 환경에서는 실제 단말에서 화면 녹화/미러링 상태로 진입하는 E2E 조작이 제한되어 코드 경로 기준으로 검증함.
|
||||||
|
|
||||||
|
### 7차 검증 (2026-03-24) — 녹화 오디오 차단 보강
|
||||||
|
- 무엇/왜/어떻게:
|
||||||
|
- 무엇: `agoraConnectSuccess` 시점에 현재 음소거 상태(`isSpeakerMute`, `isMute`)를 Agora 엔진에 재적용하고, `applyScreenCaptureProtection(isCaptured: true)`에서 role 조건을 제거해 캡쳐 상태면 마이크를 선제 음소거하도록 보강.
|
||||||
|
- 왜: 캡쳐 상태에서 선행 음소거 이후 채널 join/rejoin 기본 구독 복귀, listener→speaker 전환 시점의 짧은 오디오 누출 창을 동시에 차단하기 위해.
|
||||||
|
- 어떻게: 코드베이스 탐색(explore 2건) + 외부 문서 조사(librarian 1건)로 원인을 확정하고 최소 패치 적용.
|
||||||
|
- 실행 명령:
|
||||||
|
- `lsp_diagnostics(filePath: /Users/klaus/Develop/sodalive/iOS/SodaLive/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift, severity: all)`
|
||||||
|
- `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`
|
||||||
|
- `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive" test; xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" test`
|
||||||
|
- `Read(SodaLive/Sources/Live/Room/LiveRoomViewModel.swift:519~533)`
|
||||||
|
- `grep("func agoraConnectSuccess|if isSpeakerMute|if isMute", LiveRoomViewModel.swift)`
|
||||||
|
- `grep("func applyScreenCaptureProtection|if !viewModel.isMute|shouldRestoreMicMuteAfterCapture = true", 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(가능 범위):
|
||||||
|
- 캡쳐 진입 시 role과 무관한 마이크 선제 음소거 경로(`if !viewModel.isMute`) 확인.
|
||||||
|
- 연결 완료 시 `isSpeakerMute/isMute` 상태 재적용 경로 확인.
|
||||||
|
- CLI 한계로 실기기 녹화 E2E는 별도 디바이스 수동 검증 필요.
|
||||||
Reference in New Issue
Block a user