fix(live-room): 캡쳐 보호 음소거 동기화
This commit is contained in:
@@ -523,6 +523,14 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
||||
} else {
|
||||
role = .LISTENER
|
||||
}
|
||||
|
||||
if isSpeakerMute {
|
||||
agora.speakerMute(true)
|
||||
}
|
||||
|
||||
if isMute {
|
||||
agora.mute(true)
|
||||
}
|
||||
|
||||
DEBUG_LOG("agoraConnectSuccess")
|
||||
|
||||
@@ -673,22 +681,33 @@ final class LiveRoomViewModel: NSObject, ObservableObject {
|
||||
.store(in: &subscription)
|
||||
}
|
||||
|
||||
func toggleMute() {
|
||||
isMute.toggle()
|
||||
agora.mute(isMute)
|
||||
|
||||
if isMute {
|
||||
muteSpeakers.append(UInt(UserDefaults.int(forKey: .userId)))
|
||||
func setMute(_ isMuted: Bool) {
|
||||
isMute = isMuted
|
||||
agora.mute(isMuted)
|
||||
|
||||
let userId = UInt(UserDefaults.int(forKey: .userId))
|
||||
if isMuted {
|
||||
if !muteSpeakers.contains(userId) {
|
||||
muteSpeakers.append(userId)
|
||||
}
|
||||
} else {
|
||||
if let index = muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) {
|
||||
if let index = muteSpeakers.firstIndex(of: userId) {
|
||||
muteSpeakers.remove(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func toggleMute() {
|
||||
setMute(!isMute)
|
||||
}
|
||||
|
||||
func setSpeakerMute(_ isMuted: Bool) {
|
||||
isSpeakerMute = isMuted
|
||||
agora.speakerMute(isMuted)
|
||||
}
|
||||
|
||||
func toggleSpeakerMute() {
|
||||
isSpeakerMute.toggle()
|
||||
agora.speakerMute(isSpeakerMute)
|
||||
setSpeakerMute(!isSpeakerMute)
|
||||
}
|
||||
|
||||
func sendMessage(chatMessage: String, onSuccess: @escaping () -> Void) {
|
||||
|
||||
@@ -32,6 +32,10 @@ struct LiveRoomViewV2: View {
|
||||
@State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil
|
||||
@State private var selectedChatForDelete: LiveRoomNormalChat? = nil
|
||||
@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()
|
||||
|
||||
private var appliedKeyboardHeight: CGFloat {
|
||||
@@ -59,7 +63,8 @@ struct LiveRoomViewV2: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ScreenCaptureSecureContainer {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
@@ -527,9 +532,11 @@ struct LiveRoomViewV2: View {
|
||||
.onAppear {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
||||
viewModel.initAgoraEngine()
|
||||
// 진입 시 현재 캡쳐 상태를 즉시 동기화해 첫 프레임부터 보호 상태를 반영
|
||||
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
|
||||
|
||||
viewModel.getMemberCan()
|
||||
viewModel.initAgoraEngine()
|
||||
viewModel.getRoomInfo()
|
||||
viewModel.getBlockedMemberIdList()
|
||||
|
||||
@@ -544,6 +551,8 @@ struct LiveRoomViewV2: View {
|
||||
.onDisappear {
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
// 화면 이탈 시 캡쳐로 인해 강제 변경한 음소거 상태를 원복
|
||||
releaseForcedCaptureMute()
|
||||
viewModel.stopV2VTranslationIfJoined()
|
||||
viewModel.stopPeriodicPlaybackValidation()
|
||||
}
|
||||
@@ -845,46 +854,54 @@ struct LiveRoomViewV2: View {
|
||||
if isImageLoading {
|
||||
LoadingView()
|
||||
}
|
||||
|
||||
if isScreenCaptureProtected {
|
||||
Color.black
|
||||
.ignoresSafeArea()
|
||||
.allowsHitTesting(true)
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .center) {
|
||||
ZStack {
|
||||
// 로컬(롱프레스 중) 물 채우기 하트
|
||||
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)
|
||||
if !isScreenCaptureProtected {
|
||||
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)
|
||||
// 로컬(롱프레스 중) 물 채우기 하트
|
||||
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 {
|
||||
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)
|
||||
.drawingGroup(opaque: false, colorMode: .linear)
|
||||
// 키보드가 올라오면 중앙 하트를 위로 올려 가리지 않도록 이동
|
||||
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
|
||||
.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
|
||||
guard isLongPressingHeart else { return }
|
||||
@@ -909,11 +926,26 @@ struct LiveRoomViewV2: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
|
||||
viewModel.quitRoom()
|
||||
}
|
||||
// 시스템 캡쳐 상태 변경(녹화 시작/종료 등)에 맞춰 보호 로직 갱신
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in
|
||||
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
|
||||
}
|
||||
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
|
||||
if isFrozen {
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.role) { role in
|
||||
guard isScreenCaptureProtected,
|
||||
role == .SPEAKER,
|
||||
!viewModel.isMute else {
|
||||
return
|
||||
}
|
||||
|
||||
// 캡쳐 중 리스너→스피커 전환 시 마이크가 즉시 켜지지 않도록 강제 음소거
|
||||
viewModel.setMute(true)
|
||||
shouldRestoreMicMuteAfterCapture = true
|
||||
}
|
||||
.ignoresSafeArea(.keyboard)
|
||||
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
|
||||
.sheet(
|
||||
@@ -1022,6 +1054,7 @@ struct LiveRoomViewV2: View {
|
||||
hideKeyboard()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
if liveRoomInfo.isFollowing {
|
||||
return guestFollowButtonTypeOverride ?? .following
|
||||
|
||||
Reference in New Issue
Block a user