fix(live-room): 캡처/녹화 시 라이브룸 보안 음소거를 동기화한다
This commit is contained in:
@@ -14,6 +14,12 @@
|
||||
<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.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.READ_EXTERNAL_STORAGE"
|
||||
|
||||
@@ -6,11 +6,11 @@ import android.animation.ObjectAnimator
|
||||
import android.animation.ValueAnimator
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ResolveInfo
|
||||
@@ -39,6 +39,7 @@ import android.view.LayoutInflater
|
||||
import android.view.MotionEvent
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.view.animation.AccelerateDecelerateInterpolator
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
@@ -48,6 +49,7 @@ import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.appcompat.widget.PopupMenu
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.toColorInt
|
||||
@@ -55,7 +57,9 @@ import androidx.core.graphics.withTranslation
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
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.settings.language.LanguageManager
|
||||
import kr.co.vividnext.sodalive.settings.notification.MemberRole
|
||||
import org.koin.android.ext.android.inject
|
||||
import org.json.JSONObject
|
||||
import org.koin.android.ext.android.inject
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.function.Consumer
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.random.Random
|
||||
import io.agora.rtc2.Constants as AgoraConstants
|
||||
@@ -165,6 +170,25 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(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<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) {
|
||||
agora.sendRawMessageToPeer(
|
||||
receiverUid = peerId.toString(),
|
||||
@@ -1803,22 +2014,12 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(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<ActivityLiveRoomBinding>(ActivityLiveRoomB
|
||||
|
||||
companion object {
|
||||
private const val NO_CHATTING_TIME = 180L
|
||||
private const val SCREEN_CAPTURE_MUTE_HOLD_MILLIS = 2_000L
|
||||
var isForeground: Boolean = false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user