From 8c0690b1e5600f352de9fe7bdb6be1d3bdabc375 Mon Sep 17 00:00:00 2001 From: klaus Date: Tue, 24 Mar 2026 16:16:14 +0900 Subject: [PATCH] =?UTF-8?q?fix(live-room):=20=EC=BA=A1=EC=B2=98/=EB=85=B9?= =?UTF-8?q?=ED=99=94=20=EC=8B=9C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=A3=B8=20?= =?UTF-8?q?=EB=B3=B4=EC=95=88=20=EC=9D=8C=EC=86=8C=EA=B1=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 6 + .../sodalive/live/room/LiveRoomActivity.kt | 266 +++++++++++++++--- docs/20260324_라이브룸화면캡쳐녹화차단처리.md | 56 ++++ 3 files changed, 296 insertions(+), 32 deletions(-) create mode 100644 docs/20260324_라이브룸화면캡쳐녹화차단처리.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8659be37..4765f79a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,12 @@ + + + + + + (ActivityLiveRoomB private var isMicrophoneMute = false private var isSpeaker = false + // 캡처/녹화 감지로 인해 강제 음소거가 필요한지 추적한다. + private var isCapturePrivacyMuted = false + private var isScreenRecordingActive = false + private var isScreenshotMuteActive = false + + // 라이프사이클 중복 호출에서 콜백 재등록을 방지한다. + private var isScreenCaptureCallbackRegistered = false + private var isScreenRecordingCallbackRegistered = false + + // API 레벨별 콜백 인스턴스를 재사용해 등록/해제 짝을 보장한다. + private var screenCaptureCallback: Any? = null + private var screenRecordingCallback: Any? = null + + // 스크린샷 이벤트 직후 일정 시간 음소거를 유지한 뒤 자동 해제한다. + private val clearScreenshotMuteRunnable = Runnable { + isScreenshotMuteActive = false + syncCapturePrivacyMuteState() + } + private var isHost = false private var isAvailableLikeHeart = false @@ -352,6 +376,7 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB } private val deepLinkConfirmReceiver = object : BroadcastReceiver() { + @OptIn(UnstableApi::class) override fun onReceive(context: Context?, intent: Intent?) { val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return @@ -370,6 +395,9 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB override fun onCreate(savedInstanceState: Bundle?) { initAgora() + // 라이브룸 화면이 캡처/녹화 결과에 노출되지 않도록 보안 플래그를 적용한다. + window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + super.onCreate(savedInstanceState) applyKeyboardPanInsets() onBackPressedDispatcher.addCallback(this, onBackPressedCallback) @@ -395,6 +423,9 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB IntentFilter(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM) ) + // 포그라운드 진입 시 API 레벨별 캡처/녹화 감지를 시작한다. + registerCaptureSecurityCallbacks() + if (this::layoutManager.isInitialized) { layoutManager.scrollToPosition(chatAdapter.itemCount - 1) } @@ -415,11 +446,16 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB override fun onStop() { LocalBroadcastManager.getInstance(this).unregisterReceiver(deepLinkConfirmReceiver) + + // 백그라운드 전환 시 콜백을 해제해 누수와 오탐지를 막는다. + unregisterCaptureSecurityCallbacks() isForeground = false super.onStop() } override fun onDestroy() { + // 액티비티 종료 전에 강제 음소거 상태를 원복한다. + clearCapturePrivacyMuteState() cropper.cleanup() hideKeyboard { viewModel.quitRoom(roomId) { @@ -641,21 +677,9 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB binding.tvQuit.setOnClickListener { onClickQuit() } binding.flMicrophoneMute.setOnClickListener { microphoneMute() - if (isMicrophoneMute) { - binding.ivMicrophoneMute.setImageResource(R.drawable.ic_mic_off) - binding.ivNotiMicrophoneMute.visibility = View.VISIBLE - } else { - binding.ivMicrophoneMute.setImageResource(R.drawable.ic_mic_on) - binding.ivNotiMicrophoneMute.visibility = View.GONE - } } binding.flSpeakerMute.setOnClickListener { speakerMute() - if (isSpeakerMute) { - binding.ivSpeakerMute.setImageResource(R.drawable.ic_speaker_off) - } else { - binding.ivSpeakerMute.setImageResource(R.drawable.ic_speaker_on) - } } binding.etChat.setOnEditorActionListener { _, actionId, _ -> @@ -1518,7 +1542,7 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB rvChatBaseBottomMargin = it } - val captionHeight = if (binding.tvV2vCaption.visibility == View.VISIBLE) { + val captionHeight = if (binding.tvV2vCaption.isVisible) { binding.tvV2vCaption.height } else { 0 @@ -1547,14 +1571,14 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB private fun setAudience() { isSpeaker = false isMicrophoneMute = false - agora.muteLocalAudioStream(false) agora.setClientRole(AgoraConstants.CLIENT_ROLE_AUDIENCE) + + // 수동 mute 상태와 캡처 강제 mute를 합성해 오디오 상태를 즉시 맞춘다. + applyEffectiveAudioMuteState() handler.postDelayed({ binding.tvChangeListener.visibility = View.GONE binding.tvChangeListener.setOnClickListener { } - binding.ivMicrophoneMute.setImageResource(R.drawable.ic_mic_on) binding.flMicrophoneMute.visibility = View.GONE - binding.ivNotiMicrophoneMute.visibility = View.GONE speakerListAdapter.muteSpeakers.remove(SharedPreferenceManager.userId.toInt()) }, 100) } @@ -1562,14 +1586,201 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB private fun setBroadcaster() { isSpeaker = true isMicrophoneMute = false - agora.muteLocalAudioStream(false) agora.setClientRole(AgoraConstants.CLIENT_ROLE_BROADCASTER) + + // 역할 전환 직후에도 강제 mute 상태가 유지되도록 동기화한다. + applyEffectiveAudioMuteState() handler.postDelayed({ binding.flMicrophoneMute.visibility = View.VISIBLE - binding.ivNotiMicrophoneMute.visibility = View.GONE + updateMicrophoneMuteUi(isMicrophoneMute || isCapturePrivacyMuted) }, 100) } + private fun registerCaptureSecurityCallbacks() { + // 스크린샷/녹화 감지는 지원 API 레벨이 달라 각각 분리 등록한다. + registerScreenshotCallback() + registerScreenRecordingCallback() + } + + private fun registerScreenshotCallback() { + // Android 14(API 34)+에서만 스크린샷 감지 콜백을 등록한다. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || isScreenCaptureCallbackRegistered) { + return + } + + if (screenCaptureCallback == null) { + screenCaptureCallback = ScreenCaptureCallback { + onScreenCaptureDetected() + } + } + + registerScreenCaptureCallback( + mainExecutor, + screenCaptureCallback as ScreenCaptureCallback + ) + isScreenCaptureCallbackRegistered = true + } + + @Suppress("UNCHECKED_CAST") + private fun registerScreenRecordingCallback() { + // Android 15(API 35)+에서만 스크린녹화 상태 콜백을 등록한다. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM || isScreenRecordingCallbackRegistered) { + return + } + + if (screenRecordingCallback == null) { + screenRecordingCallback = Consumer { state -> + onScreenRecordingStateChanged( + isRecording = state == WindowManager.SCREEN_RECORDING_STATE_VISIBLE + ) + } + } + + val initialRecordingState = windowManager.addScreenRecordingCallback( + mainExecutor, + screenRecordingCallback as Consumer + ) + isScreenRecordingCallbackRegistered = true + + // 등록 시점의 현재 녹화 상태를 즉시 반영해 초기 상태 불일치를 방지한다. + onScreenRecordingStateChanged( + isRecording = initialRecordingState == WindowManager.SCREEN_RECORDING_STATE_VISIBLE + ) + } + + private fun unregisterCaptureSecurityCallbacks() { + // onStart에서 등록한 콜백을 반대로 해제하고 강제 mute 상태를 정리한다. + unregisterScreenshotCallback() + unregisterScreenRecordingCallback() + clearCapturePrivacyMuteState() + } + + private fun unregisterScreenshotCallback() { + // Android 14+에서 등록된 스크린샷 콜백만 안전하게 해제한다. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || !isScreenCaptureCallbackRegistered) { + return + } + + val callback = screenCaptureCallback as? ScreenCaptureCallback + if (callback != null) { + unregisterScreenCaptureCallback(callback) + } + isScreenCaptureCallbackRegistered = false + } + + @Suppress("UNCHECKED_CAST") + private fun unregisterScreenRecordingCallback() { + // Android 15+에서 등록된 녹화 상태 콜백만 안전하게 해제한다. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM || !isScreenRecordingCallbackRegistered) { + return + } + + val callback = screenRecordingCallback as? Consumer + if (callback != null) { + windowManager.removeScreenRecordingCallback(callback) + } + isScreenRecordingCallbackRegistered = false + } + + private fun onScreenCaptureDetected() { + // 스크린샷 감지 직후 짧은 윈도우 동안 강제 mute를 유지해 유출 여지를 줄인다. + isScreenshotMuteActive = true + syncCapturePrivacyMuteState() + handler.removeCallbacks(clearScreenshotMuteRunnable) + handler.postDelayed(clearScreenshotMuteRunnable, SCREEN_CAPTURE_MUTE_HOLD_MILLIS) + } + + private fun onScreenRecordingStateChanged(isRecording: Boolean) { + // 시스템이 알려준 녹화 가시 상태를 강제 mute 계산에 반영한다. + isScreenRecordingActive = isRecording + syncCapturePrivacyMuteState() + } + + private fun clearCapturePrivacyMuteState() { + // 라이프사이클 해제 시 캡처 기반 플래그를 모두 초기화해 원복을 보장한다. + handler.removeCallbacks(clearScreenshotMuteRunnable) + isScreenRecordingActive = false + isScreenshotMuteActive = false + syncCapturePrivacyMuteState() + } + + private fun syncCapturePrivacyMuteState() { + // 스크린샷/녹화 감지를 하나의 강제 mute 상태로 통합한다. + val shouldMute = isScreenRecordingActive || isScreenshotMuteActive + if (isCapturePrivacyMuted == shouldMute) { + return + } + + isCapturePrivacyMuted = shouldMute + applyEffectiveAudioMuteState() + } + + private fun applyEffectiveAudioMuteState() { + // 사용자 토글 mute와 캡처 강제 mute를 OR 결합해 실제 오디오 상태를 계산한다. + val shouldMuteMicrophone = isMicrophoneMute || isCapturePrivacyMuted + val shouldMuteSpeaker = isSpeakerMute || isCapturePrivacyMuted + + // 계산된 결과를 Agora 엔진과 UI 상태에 동시에 반영한다. + agora.muteLocalAudioStream(shouldMuteMicrophone) + agora.muteAllRemoteAudioStreams(shouldMuteSpeaker) + + updateMicrophoneMuteUi(shouldMuteMicrophone) + updateSpeakerMuteUi(shouldMuteSpeaker) + updateSelfMuteUiState(shouldMuteMicrophone) + } + + private fun updateMicrophoneMuteUi(isMuted: Boolean) { + // 마이크 아이콘과 알림 배지를 동일 기준으로 갱신한다. + binding.ivMicrophoneMute.setImageResource( + if (isMuted) { + R.drawable.ic_mic_off + } else { + R.drawable.ic_mic_on + } + ) + binding.ivNotiMicrophoneMute.visibility = if (isMuted) { + View.VISIBLE + } else { + View.GONE + } + } + + private fun updateSpeakerMuteUi(isMuted: Boolean) { + // 스피커 아이콘을 현재 mute 상태와 동기화한다. + binding.ivSpeakerMute.setImageResource( + if (isMuted) { + R.drawable.ic_speaker_off + } else { + R.drawable.ic_speaker_on + } + ) + } + + @SuppressLint("NotifyDataSetChanged") + private fun updateSelfMuteUiState(isMuted: Boolean) { + // 방장/청취자 표시 규칙에 맞춰 자기 자신의 mute 표시를 갱신한다. + if (!viewModel.isRoomInfoInitialized()) { + return + } + + if (SharedPreferenceManager.userId == viewModel.roomInfoResponse.creatorId) { + setMuteSpeakerCreator(isMuted) + return + } + + if (!this::speakerListAdapter.isInitialized) { + return + } + + val userId = SharedPreferenceManager.userId.toInt() + if (isMuted) { + speakerListAdapter.muteSpeakers.add(userId) + } else { + speakerListAdapter.muteSpeakers.remove(userId) + } + speakerListAdapter.notifyDataSetChanged() + } + private fun changeListenerMessage(peerId: Long, isFromManager: Boolean = false) { agora.sendRawMessageToPeer( receiverUid = peerId.toString(), @@ -1803,22 +2014,12 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB private fun microphoneMute() { isMicrophoneMute = !isMicrophoneMute - agora.muteLocalAudioStream(isMicrophoneMute) - - if (SharedPreferenceManager.userId == viewModel.roomInfoResponse.creatorId) { - setMuteSpeakerCreator(isMicrophoneMute) - } else { - if (isMicrophoneMute) { - speakerListAdapter.muteSpeakers.add(SharedPreferenceManager.userId.toInt()) - } else { - speakerListAdapter.muteSpeakers.remove(SharedPreferenceManager.userId.toInt()) - } - } + applyEffectiveAudioMuteState() } private fun speakerMute() { isSpeakerMute = !isSpeakerMute - agora.muteAllRemoteAudioStreams(isSpeakerMute) + applyEffectiveAudioMuteState() } @SuppressLint("SetTextI18n") @@ -4029,6 +4230,7 @@ class LiveRoomActivity : BaseActivity(ActivityLiveRoomB companion object { private const val NO_CHATTING_TIME = 180L + private const val SCREEN_CAPTURE_MUTE_HOLD_MILLIS = 2_000L var isForeground: Boolean = false } } diff --git a/docs/20260324_라이브룸화면캡쳐녹화차단처리.md b/docs/20260324_라이브룸화면캡쳐녹화차단처리.md new file mode 100644 index 00000000..0943287f --- /dev/null +++ b/docs/20260324_라이브룸화면캡쳐녹화차단처리.md @@ -0,0 +1,56 @@ +# 20260324 라이브룸 화면 캡쳐/녹화 차단 처리 계획 + +## 구현 체크리스트 +- [x] LiveRoomActivity의 기존 오디오 음소거/복원 흐름을 탐색해 캡쳐/녹화 감지 시 재사용 지점을 확정한다. (QA: 기존 `muteAllRemoteAudioStreams`/마이크 음소거 관련 코드 경로 확인) +- [x] 화면 캡쳐/녹화 시작 상태를 감지하는 Android API 적용 방식을 확정한다. (QA: 코드베이스 탐색 + 공식 문서 근거 확인) +- [x] 감지 시 캡쳐 결과 배경이 검정색이 되도록 처리한다. (QA: `FLAG_SECURE` 또는 동등 보호 처리 코드 반영 확인) +- [x] 감지 시 음소거가 적용되고 종료 시 원복되도록 처리한다. (QA: 상태 플래그 기반 mute/unmute 분기 확인) +- [x] 수정 코드 진단/테스트/빌드/수동 검증 결과를 기록한다. (QA: 실행 명령과 결과 로그 확인) + +## 검증 기록 +- 2026-03-24 + - 무엇: 작업 계획 문서를 생성하고 구현/검증 체크리스트를 정의했다. + - 왜: 구현 범위와 완료 기준을 명확히 고정한 상태에서 안전하게 변경하기 위해서다. + - 어떻게: `docs/20260324_라이브룸화면캡쳐녹화차단처리.md` 파일을 생성해 체크박스와 QA 기준을 작성했다. +- 2026-03-24 + - 무엇: 코드베이스/외부 문서 병렬 탐색으로 캡쳐·녹화 보호 및 음소거 적용 근거를 확정했다. + - 왜: 기존 구현 패턴과 Android/Agora API 제약을 확인한 뒤 안전한 최소 변경으로 적용하기 위해서다. + - 어떻게: + - 코드 탐색: `grep`, `ast_grep_search`, `sg run`, `read`로 `LiveRoomActivity`, `Agora.kt`, `BaseActivity`, `AndroidManifest.xml` 확인. + - 배경 탐색: `explore` 3건(`bg_7ba54780`, `bg_885fde99`, `bg_bbf958e8`), `librarian` 2건(`bg_ad1f9b7a`, `bg_486409ac`) 결과 수집. + - API 레벨 검증: `/Users/klaus/Library/Android/sdk/platforms/android-35/data/api-versions.xml`에서 `addScreenRecordingCallback`/`removeScreenRecordingCallback`/`SCREEN_RECORDING_STATE_VISIBLE`가 `since="35"`임을 확인. +- 2026-03-24 + - 무엇: LiveRoomActivity에 캡쳐/녹화 보안 처리와 음소거 동기화 로직을 구현했다. + - 왜: 화면 캡쳐/녹화 시 민감 화면이 노출되지 않고 오디오가 즉시 음소거되도록 하기 위해서다. + - 어떻게: + - `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)`를 `onCreate`에 추가해 캡쳐 결과 비표시(검정/빈 화면) 처리. + - API 34+ `registerScreenCaptureCallback`(스크린샷), API 35+ `addScreenRecordingCallback`(녹화 상태) 등록/해제 로직을 `onStart`/`onStop`에 추가. + - `isCapturePrivacyMuted`, `isScreenRecordingActive`, `isScreenshotMuteActive` 상태를 기반으로 `agora.muteLocalAudioStream`/`agora.muteAllRemoteAudioStreams`를 일괄 적용하고 종료 시 원복. + - 기존 마이크/스피커 토글 로직을 `applyEffectiveAudioMuteState()`로 통합해 UI/실제 mute 상태를 동기화. +- 2026-03-24 + - 무엇: 스크린샷/녹화 감지 콜백 동작을 위한 권한을 Manifest에 반영했다. + - 왜: Android 14+ 스크린샷 감지와 Android 15+ 녹화 상태 감지 콜백의 권한 요구사항을 충족하기 위해서다. + - 어떻게: `app/src/main/AndroidManifest.xml`에 ``, ``를 추가했다. +- 2026-03-24 + - 무엇: 구현 완료 후 Oracle 리뷰로 API/권한 회귀 위험을 점검하고 보완 반영했다. + - 왜: `addScreenRecordingCallback` 사용 시 Android 15 권한 누락으로 인한 `SecurityException` 가능성을 제거하기 위해서다. + - 어떻게: Oracle 결과(`ses_2e18fd285ffefWU6DrqSIJbgWY`)를 수집해 `DETECT_SCREEN_RECORDING` 권한을 추가하고 재빌드/재검증했다. +- 2026-03-24 + - 무엇: 수정분 정합성 검증(진단/테스트/빌드/수동 QA)을 수행했다. + - 왜: 컴파일 안정성과 요구사항 반영 여부를 객관적으로 확인하기 위해서다. + - 어떻게: + - LSP 진단: `.kt` LSP 서버 미구성으로 `lsp_diagnostics` 실행 불가 확인. + - 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 결과: `BUILD SUCCESSFUL` (수정 단계별 반복 실행 모두 성공, 기존 Gradle deprecation/namespace 경고만 존재) + - 수동 QA 명령: `python3` 스크립트로 `FLAG_SECURE`, API 34/35 콜백 등록/해제, 캡쳐 기반 음소거 동기화, Manifest 권한 선언을 검증. + - 수동 QA 결과: `MANUAL QA PASS: capture/record security + mute flow + required permissions verified from source.` +- 2026-03-24 + - 무엇: 사용자 요청에 맞춰 캡처/녹화 관련 추가 코드 전반에 의미 단위 주석을 보강했다. + - 왜: API 레벨 분기(34/35), 콜백 등록/해제 대칭, 강제 mute 원복 의도를 유지보수 시 즉시 파악할 수 있도록 하기 위해서다. + - 어떻게: + - `LiveRoomActivity.kt`의 신규 상태 변수, 라이프사이클 훅, 콜백 등록/해제, 강제 mute 계산/적용 함수에 한 문장 주석을 추가했다. + - `AndroidManifest.xml`의 `DETECT_SCREEN_CAPTURE`/`DETECT_SCREEN_RECORDING` 권한 선언 위에 목적 주석을 추가했다. + - 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug` + - 결과: `BUILD SUCCESSFUL` + - 수동 QA 명령: `python3` 스크립트로 주석 반영 지점을 점검. + - 수동 QA 결과: `MANUAL QA PASS: meaning-unit comments added for all capture/recording additions.`