fix(live-room): 캡처/녹화 시 라이브룸 보안 음소거를 동기화한다

This commit is contained in:
2026-03-24 16:16:14 +09:00
parent 08524bd79a
commit 8c0690b1e5
3 changed files with 296 additions and 32 deletions

View File

@@ -14,6 +14,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Android 14+ 스크린샷 감지 콜백(registerScreenCaptureCallback)에 필요한 권한 -->
<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />
<!-- Android 15+ 녹화 상태 콜백(addScreenRecordingCallback)에 필요한 권한 -->
<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission <uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE" android:name="android.permission.READ_EXTERNAL_STORAGE"

View File

@@ -6,11 +6,11 @@ import android.animation.ObjectAnimator
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.AlertDialog import android.app.AlertDialog
import android.content.BroadcastReceiver
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.BroadcastReceiver
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.content.pm.ResolveInfo import android.content.pm.ResolveInfo
@@ -39,6 +39,7 @@ import android.view.LayoutInflater
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AccelerateDecelerateInterpolator
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
@@ -48,6 +49,7 @@ import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.OptIn
import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.PopupMenu
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.graphics.toColorInt import androidx.core.graphics.toColorInt
@@ -55,7 +57,9 @@ import androidx.core.graphics.withTranslation
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.transform.CircleCropTransformation import coil.transform.CircleCropTransformation
@@ -122,9 +126,10 @@ import kr.co.vividnext.sodalive.report.ReportType
import kr.co.vividnext.sodalive.report.UserReportDialog import kr.co.vividnext.sodalive.report.UserReportDialog
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
import org.koin.android.ext.android.inject
import org.json.JSONObject import org.json.JSONObject
import org.koin.android.ext.android.inject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.function.Consumer
import java.util.regex.Pattern import java.util.regex.Pattern
import kotlin.random.Random import kotlin.random.Random
import io.agora.rtc2.Constants as AgoraConstants import io.agora.rtc2.Constants as AgoraConstants
@@ -165,6 +170,25 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private var isMicrophoneMute = false private var isMicrophoneMute = false
private var isSpeaker = 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 isHost = false
private var isAvailableLikeHeart = false private var isAvailableLikeHeart = false
@@ -352,6 +376,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
private val deepLinkConfirmReceiver = object : BroadcastReceiver() { private val deepLinkConfirmReceiver = object : BroadcastReceiver() {
@OptIn(UnstableApi::class)
override fun onReceive(context: Context?, intent: Intent?) { override fun onReceive(context: Context?, intent: Intent?) {
val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return val bundle = intent?.getBundleExtra(Constants.EXTRA_DATA) ?: return
@@ -370,6 +395,9 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
initAgora() initAgora()
// 라이브룸 화면이 캡처/녹화 결과에 노출되지 않도록 보안 플래그를 적용한다.
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
applyKeyboardPanInsets() applyKeyboardPanInsets()
onBackPressedDispatcher.addCallback(this, onBackPressedCallback) onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
@@ -395,6 +423,9 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
IntentFilter(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM) IntentFilter(Constants.ACTION_LIVE_ROOM_DEEPLINK_CONFIRM)
) )
// 포그라운드 진입 시 API 레벨별 캡처/녹화 감지를 시작한다.
registerCaptureSecurityCallbacks()
if (this::layoutManager.isInitialized) { if (this::layoutManager.isInitialized) {
layoutManager.scrollToPosition(chatAdapter.itemCount - 1) layoutManager.scrollToPosition(chatAdapter.itemCount - 1)
} }
@@ -415,11 +446,16 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
override fun onStop() { override fun onStop() {
LocalBroadcastManager.getInstance(this).unregisterReceiver(deepLinkConfirmReceiver) LocalBroadcastManager.getInstance(this).unregisterReceiver(deepLinkConfirmReceiver)
// 백그라운드 전환 시 콜백을 해제해 누수와 오탐지를 막는다.
unregisterCaptureSecurityCallbacks()
isForeground = false isForeground = false
super.onStop() super.onStop()
} }
override fun onDestroy() { override fun onDestroy() {
// 액티비티 종료 전에 강제 음소거 상태를 원복한다.
clearCapturePrivacyMuteState()
cropper.cleanup() cropper.cleanup()
hideKeyboard { hideKeyboard {
viewModel.quitRoom(roomId) { viewModel.quitRoom(roomId) {
@@ -641,21 +677,9 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
binding.tvQuit.setOnClickListener { onClickQuit() } binding.tvQuit.setOnClickListener { onClickQuit() }
binding.flMicrophoneMute.setOnClickListener { binding.flMicrophoneMute.setOnClickListener {
microphoneMute() 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 { binding.flSpeakerMute.setOnClickListener {
speakerMute() speakerMute()
if (isSpeakerMute) {
binding.ivSpeakerMute.setImageResource(R.drawable.ic_speaker_off)
} else {
binding.ivSpeakerMute.setImageResource(R.drawable.ic_speaker_on)
}
} }
binding.etChat.setOnEditorActionListener { _, actionId, _ -> binding.etChat.setOnEditorActionListener { _, actionId, _ ->
@@ -1518,7 +1542,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
rvChatBaseBottomMargin = it rvChatBaseBottomMargin = it
} }
val captionHeight = if (binding.tvV2vCaption.visibility == View.VISIBLE) { val captionHeight = if (binding.tvV2vCaption.isVisible) {
binding.tvV2vCaption.height binding.tvV2vCaption.height
} else { } else {
0 0
@@ -1547,14 +1571,14 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private fun setAudience() { private fun setAudience() {
isSpeaker = false isSpeaker = false
isMicrophoneMute = false isMicrophoneMute = false
agora.muteLocalAudioStream(false)
agora.setClientRole(AgoraConstants.CLIENT_ROLE_AUDIENCE) agora.setClientRole(AgoraConstants.CLIENT_ROLE_AUDIENCE)
// 수동 mute 상태와 캡처 강제 mute를 합성해 오디오 상태를 즉시 맞춘다.
applyEffectiveAudioMuteState()
handler.postDelayed({ handler.postDelayed({
binding.tvChangeListener.visibility = View.GONE binding.tvChangeListener.visibility = View.GONE
binding.tvChangeListener.setOnClickListener { } binding.tvChangeListener.setOnClickListener { }
binding.ivMicrophoneMute.setImageResource(R.drawable.ic_mic_on)
binding.flMicrophoneMute.visibility = View.GONE binding.flMicrophoneMute.visibility = View.GONE
binding.ivNotiMicrophoneMute.visibility = View.GONE
speakerListAdapter.muteSpeakers.remove(SharedPreferenceManager.userId.toInt()) speakerListAdapter.muteSpeakers.remove(SharedPreferenceManager.userId.toInt())
}, 100) }, 100)
} }
@@ -1562,14 +1586,201 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private fun setBroadcaster() { private fun setBroadcaster() {
isSpeaker = true isSpeaker = true
isMicrophoneMute = false isMicrophoneMute = false
agora.muteLocalAudioStream(false)
agora.setClientRole(AgoraConstants.CLIENT_ROLE_BROADCASTER) agora.setClientRole(AgoraConstants.CLIENT_ROLE_BROADCASTER)
// 역할 전환 직후에도 강제 mute 상태가 유지되도록 동기화한다.
applyEffectiveAudioMuteState()
handler.postDelayed({ handler.postDelayed({
binding.flMicrophoneMute.visibility = View.VISIBLE binding.flMicrophoneMute.visibility = View.VISIBLE
binding.ivNotiMicrophoneMute.visibility = View.GONE updateMicrophoneMuteUi(isMicrophoneMute || isCapturePrivacyMuted)
}, 100) }, 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<Int> { state ->
onScreenRecordingStateChanged(
isRecording = state == WindowManager.SCREEN_RECORDING_STATE_VISIBLE
)
}
}
val initialRecordingState = windowManager.addScreenRecordingCallback(
mainExecutor,
screenRecordingCallback as Consumer<Int>
)
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<Int>
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) { private fun changeListenerMessage(peerId: Long, isFromManager: Boolean = false) {
agora.sendRawMessageToPeer( agora.sendRawMessageToPeer(
receiverUid = peerId.toString(), receiverUid = peerId.toString(),
@@ -1803,22 +2014,12 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
private fun microphoneMute() { private fun microphoneMute() {
isMicrophoneMute = !isMicrophoneMute isMicrophoneMute = !isMicrophoneMute
agora.muteLocalAudioStream(isMicrophoneMute) applyEffectiveAudioMuteState()
if (SharedPreferenceManager.userId == viewModel.roomInfoResponse.creatorId) {
setMuteSpeakerCreator(isMicrophoneMute)
} else {
if (isMicrophoneMute) {
speakerListAdapter.muteSpeakers.add(SharedPreferenceManager.userId.toInt())
} else {
speakerListAdapter.muteSpeakers.remove(SharedPreferenceManager.userId.toInt())
}
}
} }
private fun speakerMute() { private fun speakerMute() {
isSpeakerMute = !isSpeakerMute isSpeakerMute = !isSpeakerMute
agora.muteAllRemoteAudioStreams(isSpeakerMute) applyEffectiveAudioMuteState()
} }
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
@@ -4029,6 +4230,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
companion object { companion object {
private const val NO_CHATTING_TIME = 180L private const val NO_CHATTING_TIME = 180L
private const val SCREEN_CAPTURE_MUTE_HOLD_MILLIS = 2_000L
var isForeground: Boolean = false var isForeground: Boolean = false
} }
} }

View File

@@ -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``<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />`, `<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" />`를 추가했다.
- 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.`