Compare commits

3 Commits

5 changed files with 204 additions and 8 deletions

View File

@@ -63,8 +63,8 @@ android {
applicationId "kr.co.vividnext.sodalive" applicationId "kr.co.vividnext.sodalive"
minSdk 23 minSdk 23
targetSdk 35 targetSdk 35
versionCode 233 versionCode 234
versionName "1.54.1" versionName "1.54.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }

View File

@@ -190,6 +190,10 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
// joinChannel 중복 호출 방지 플래그 // joinChannel 중복 호출 방지 플래그
private var hasInvokedJoinChannel = false private var hasInvokedJoinChannel = false
// RTM/RTC 연결 완료 추적 플래그 (둘 다 연결되면 레이아웃 강제 갱신)
private var isRtcJoined = false
private var isRtmJoined = false
private var v2vSourceLanguage: String? = null private var v2vSourceLanguage: String? = null
private var v2vTargetLanguage: String? = null private var v2vTargetLanguage: String? = null
private var isV2vAvailable = false private var isV2vAvailable = false
@@ -415,7 +419,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
) )
// 포그라운드 진입 시 API 레벨별 캡처/녹화 감지를 시작한다. // 포그라운드 진입 시 API 레벨별 캡처/녹화 감지를 시작한다.
registerCaptureSecurityCallbacks() syncCaptureSecurityPolicyByRole()
if (this::layoutManager.isInitialized) { if (this::layoutManager.isInitialized) {
layoutManager.scrollToPosition(chatAdapter.itemCount - 1) layoutManager.scrollToPosition(chatAdapter.itemCount - 1)
@@ -731,6 +735,41 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
// endregion // endregion
// RTM과 RTC가 모두 연결되면 키보드를 잠깐 올렸다 내려 레이아웃을 강제 갱신한다.
// 로딩 다이얼로그가 화면을 덮고 있는 동안 수행하여 사용자에게 변화가 보이지 않도록 한다.
private fun tryForceLayoutRefresh(): Boolean {
if (!isRtcJoined || !isRtmJoined) return false
handler.post {
// 키보드가 화면을 밀어올리지 않도록 임시로 adjustNothing 전환
window.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING
)
binding.etChat.requestFocus()
imm.showSoftInput(binding.etChat, InputMethodManager.SHOW_IMPLICIT)
handler.postDelayed({
imm.hideSoftInputFromWindow(
binding.etChat.windowToken,
InputMethodManager.HIDE_NOT_ALWAYS
)
binding.etChat.clearFocus()
// 원래 softInputMode 복원
window.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
or WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN
)
// 키보드 트릭 완료 후 로딩 다이얼로그 dismiss
loadingDialog.dismiss()
}, 200)
}
return true
}
private fun applyKeyboardPanInsets() { private fun applyKeyboardPanInsets() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return return
@@ -1256,6 +1295,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
isHost = response.creatorId == SharedPreferenceManager.userId isHost = response.creatorId == SharedPreferenceManager.userId
syncCaptureSecurityPolicyByRole()
binding.tvChatFreezeSwitch.visibility = if (isHost) { binding.tvChatFreezeSwitch.visibility = if (isHost) {
View.VISIBLE View.VISIBLE
} else { } else {
@@ -1605,8 +1645,18 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}, 100) }, 100)
} }
private fun registerCaptureSecurityCallbacks() { private fun syncCaptureSecurityPolicyByRole() {
registerScreenRecordingCallback() if (isHost) {
window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
unregisterScreenRecordingCallback()
clearCapturePrivacyMuteState()
return
}
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
if (isForeground) {
registerScreenRecordingCallback()
}
} }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@@ -1669,7 +1719,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
private fun syncCapturePrivacyMuteState() { private fun syncCapturePrivacyMuteState() {
val shouldMute = isScreenRecordingActive val shouldMute = !isHost && isScreenRecordingActive
if (isCapturePrivacyMuted == shouldMute) { if (isCapturePrivacyMuted == shouldMute) {
return return
} }
@@ -2177,6 +2227,8 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
override fun onJoinChannelSuccess(channel: String, uid: Int, elapsed: Int) { override fun onJoinChannelSuccess(channel: String, uid: Int, elapsed: Int) {
super.onJoinChannelSuccess(channel, uid, elapsed) super.onJoinChannelSuccess(channel, uid, elapsed)
Logger.e("onJoinChannelSuccess - uid: $uid, channel: $channel") Logger.e("onJoinChannelSuccess - uid: $uid, channel: $channel")
isRtcJoined = true
tryForceLayoutRefresh()
} }
override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) { override fun onStreamMessage(uid: Int, streamId: Int, data: ByteArray?) {
@@ -2823,8 +2875,10 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
rtmToken = roomInfo.rtmToken, rtmToken = roomInfo.rtmToken,
channelName = roomInfo.channelName, channelName = roomInfo.channelName,
rtmChannelJoinSuccess = { rtmChannelJoinSuccess = {
handler.post { isRtmJoined = true
loadingDialog.dismiss() // 두 채널 모두 연결 시 키보드 트릭 후 dismiss, 아니면 즉시 dismiss
if (!tryForceLayoutRefresh()) {
handler.post { loadingDialog.dismiss() }
} }
if (userId == roomInfo.creatorId) { if (userId == roomInfo.creatorId) {

View File

@@ -0,0 +1,43 @@
# 20260328 라이브룸 방장 캡쳐/녹화 허용
## 구현 체크리스트
- [x] 방장 판별 시점과 캡처 보안 적용 지점을 확인한다. (QA: `isHost` 갱신 지점과 `FLAG_SECURE` 적용 지점 라인 확인)
- [x] 방장일 때만 `FLAG_SECURE`를 해제하고, 청취자는 기존 차단 상태를 유지한다. (QA: 방장/비방장 분기에서 `addFlags`/`clearFlags` 동작 확인)
- [x] 방장일 때 녹화 감지 기반 강제 mute가 적용되지 않도록 정합을 맞춘다. (QA: `syncCapturePrivacyMuteState` 분기 및 콜백 등록/해제 흐름 확인)
- [x] 진단/빌드/테스트/수동 QA를 수행하고 결과를 기록한다. (QA: 실행 명령과 결과 로그 확인)
## 검증 기록
- 2026-03-28
- 무엇: 방장 예외 적용을 위한 코드베이스/외부 레퍼런스 병렬 탐색을 수행했다.
- 왜: `FLAG_SECURE`를 역할 기반으로 런타임 토글할 때 라이프사이클/콜백 경합 없이 최소 변경으로 구현하기 위해서다.
- 어떻게:
- 내부 탐색(`explore`):
- `bg_016c0dfd` (host 보안 플로우 맵)
- `bg_ba4aa673` (host 판별 지연 시 race 위험 분석)
- `bg_3132d80b` (저장소 내 역할 기반 secure 패턴 탐색)
- 외부 탐색(`librarian`):
- `bg_1875bb8f` (Android 공식 문서/AOSP의 addFlags/clearFlags 근거)
- `bg_d010820d` (OSS 동적 토글 사례: Fenix/Signal/Bitwarden)
- 직접 검색:
- `grep`/`ast_grep_search`/`sg run`으로 `FLAG_SECURE`, `isHost`, 콜백 등록/해제, mute 계산식을 교차 확인
- `rg`는 로컬 환경에 설치되어 있지 않아(`command -v rg` 결과 없음) `grep`/`sg`로 대체 검증
- 2026-03-28
- 무엇: `LiveRoomActivity`에 방장 전용 캡처/녹화 허용 정책을 구현했다.
- 왜: 사용자 요청대로 방장(host)은 캡처/화면녹화를 허용하고, 청취자는 기존 차단 정책을 유지해야 하기 때문이다.
- 어떻게:
- `isHost` 판별 직후 정책 동기화를 위해 `syncCaptureSecurityPolicyByRole()`를 추가했다.
- 방장(`isHost=true`): `window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)` + 녹화 콜백 해제 + 강제 mute 상태 정리
- 청취자(`isHost=false`): `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)` + 포그라운드에서 녹화 콜백 등록 유지
- `viewModel.roomInfoLiveData.observe`에서 `isHost` 갱신 직후 `syncCaptureSecurityPolicyByRole()`를 호출해 비동기 roomInfo 도착 시점에도 즉시 반영되도록 했다.
- `syncCapturePrivacyMuteState()``val shouldMute = !isHost && isScreenRecordingActive`로 변경해 방장은 녹화 중에도 강제 mute 대상에서 제외했다.
- 2026-03-28
- 무엇: 진단/빌드/테스트/수동 QA를 완료했다.
- 왜: 컴파일 안정성과 요청 동작(방장 허용, 청취자 유지)을 실제 증거로 확인하기 위해서다.
- 어떻게:
- LSP 진단: `.kt` 서버 미구성으로 `lsp_diagnostics` 불가(환경 제약 확인), `.md` 파일 diagnostics는 없음.
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`
- 수동 QA 명령: `python3` 스크립트로 정책 함수/분기/호출 순서/mute 계산식을 점검
- 수동 QA 결과: `MANUAL_QA_PASS: host can bypass capture security while listeners remain protected in source flow`

View File

@@ -0,0 +1,42 @@
# 20260328 라이브룸 캡쳐/화면녹화 차단 점검
## 구현/점검 체크리스트
- [x] `LiveRoomActivity` 내 캡쳐/화면녹화 차단 적용 지점 확인
- [x] 전체 코드베이스에서 차단 해제/우회 가능 경로(`clearFlags`, `FLAG_SECURE` 재설정 등) 탐색
- [x] 화면녹화 감지 및 후속 처리(음소거/콜백 등록 해제) 로직 검증
- [x] 외부 레퍼런스(Android 공식 동작)와 현재 구현 정합성 검증
- [x] 점검 결과 및 근거(명령/파일/라인) 기록
## 검증 기록
- 2026-03-28
- 무엇: 라이브룸 캡처/화면녹화 차단 적용 여부를 코드베이스 전역과 외부 레퍼런스로 교차 점검했다.
- 왜: 사용자 질문("현재 모든 사람이 캡쳐와 화면녹화가 불가능한지")에 대해 단일 파일 확인이 아닌 우회 경로/플랫폼 제약까지 포함한 근거를 확보해야 했기 때문이다.
- 어떻게:
- 병렬 탐색(내부): `explore` 에이전트 3건
- `bg_baa23d06` (LiveRoomActivity 보안 플로우 추적)
- `bg_c991f78f` (우회/해제 경로 탐색)
- `bg_a5c8b08e` (대체 라이브 진입 화면 탐색)
- 병렬 탐색(외부): `librarian` 에이전트 2건
- `bg_b2336b84` (Android 공식 동작/제약)
- `bg_320d7f9b` (OSS 구현 패턴 비교)
- 직접 검색/정적 검증 명령:
- `grep "FLAG_SECURE|registerScreenRecordingCallback|addScreenRecordingCallback|removeScreenRecordingCallback"` (저장소 전역)
- `ast_grep_search`/`sg run`으로 `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)``window.clearFlags(...)` 존재 여부 확인
- `grep "clearFlags\(|setFlags\("` (app/src/main/java 전역)
- `read``LiveRoomActivity.kt` 라이프사이클/콜백/mute 처리 라인 직접 확인
- SDK 레퍼런스 확인: `/Users/klaus/Library/Android/sdk/platforms/android-35/data/api-versions.xml`
- 결과(핵심 근거):
- `LiveRoomActivity.kt:390`에서 `window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)`가 무조건 적용된다.
- 앱 코드에서 `window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)`는 발견되지 않았다(`sg run`/`ast_grep_search`/`grep` 교차 검증).
- 녹화 감지/후처리 로직:
- 등록: `LiveRoomActivity.kt:1627` (`windowManager.addScreenRecordingCallback`)
- 해제: `LiveRoomActivity.kt:1654` (`windowManager.removeScreenRecordingCallback`)
- 상태 반영: `LiveRoomActivity.kt:1659-1693` (`isScreenRecordingActive` -> `isCapturePrivacyMuted` -> `applyEffectiveAudioMuteState`)
- API 레벨 근거:
- `addScreenRecordingCallback`/`removeScreenRecordingCallback`/`SCREEN_RECORDING_STATE_VISIBLE`는 API 35부터(`api-versions.xml:70441,70451,70475`).
- `FLAG_SECURE`는 Android 플랫폼 상수로 존재(`api-versions.xml:70558`).
- 권한 근거:
- `AndroidManifest.xml:19``<uses-permission android:name="android.permission.DETECT_SCREEN_RECORDING" />` 선언이 존재한다.
- 결론:
- **LiveRoomActivity 화면에 입장한 사용자 기준으로는 캡처/녹화 노출이 차단되도록 구현되어 있다(FLAG_SECURE 적용 + 해제 경로 부재).**
- 다만 Android 공식 문서 범위상 `FLAG_SECURE`는 기본적으로 스크린샷/비보안 디스플레이 노출 차단을 보장하며, 모든 녹화 시나리오 100% 차단을 플랫폼이 절대 보장한다고 단정할 수는 없다.

View File

@@ -0,0 +1,57 @@
# 라이브룸 UI 미갱신 버그 수정
## 현상
- 라이브 입장 후 공지, 메뉴판 터치 시 UI가 보이지 않음
- 상대방 채팅이 화면에 갱신되지 않음
- 방장이 아닌 유저에게서 두드러지게 나타남
- 키보드가 올라오거나 화면에 변화가 생기면 모든 것이 해결됨
## 원인 분석
- `BaseActivity`에서 `WindowCompat.setDecorFitsSystemWindows(window, false)` (edge-to-edge) 적용
- `LiveRoomActivity`의 manifest에 `adjustPan` 설정과 edge-to-edge가 충돌
- `adjustPan`은 시스템이 창을 pan 하려 하지만, edge-to-edge 모드에서는 앱이 insets을 직접 처리
- 이 충돌로 DecorView 내부 스크롤 트래킹 상태가 불일치하여 `invalidate()` 더티 영역 계산 오류 발생
- 키보드가 올라가면 시스템이 WindowInsets를 재분배하고, `OnApplyWindowInsetsListener`에서 `setPadding()` 호출 → `requestLayout()` → 전체 레이아웃 패스가 강제 수행되어 해결됨
## 수정 방법
- RTM과 RTC가 모두 연결 완료된 시점에 키보드를 프로그래밍적으로 올렸다 내려 레이아웃을 강제 갱신
- `isRtcJoined`, `isRtmJoined` 플래그로 두 연결 상태를 추적
- 두 플래그가 모두 true가 되면 `tryForceLayoutRefresh()`를 호출
### 눈속임 처리 (사용자에게 변화가 보이지 않도록)
1. 로딩 다이얼로그가 화면을 덮고 있는 동안 키보드 트릭을 수행
2. `adjustNothing`으로 임시 전환하여 키보드가 화면을 밀어올리지 않도록 방지
3. 키보드 show → 200ms 후 hide → `adjustPan` 복원 → 로딩 다이얼로그 dismiss
4. RTM 콜백의 `loadingDialog.dismiss()``tryForceLayoutRefresh()` 내부로 이동
## 수정 계획
- [x] `isRtcJoined`, `isRtmJoined` 플래그 추가
- [x] `onJoinChannelSuccess`에서 `isRtcJoined = true` 설정 및 `tryForceLayoutRefresh()` 호출
- [x] RTM 성공 콜백에서 `isRtmJoined = true` 설정 및 `tryForceLayoutRefresh()` 호출
- [x] `tryForceLayoutRefresh()` 메서드 구현
- [x] adjustNothing 임시 전환으로 화면 이동 방지
- [x] 로딩 다이얼로그 뒤에서 키보드 트릭 수행
- [x] 완료 후 adjustPan 복원 및 로딩 다이얼로그 dismiss
- [x] Boolean 반환으로 RTM 콜백에서 fallback dismiss 처리
- [x] 빌드 검증
## 검증 기록
### 2026-03-29 빌드 검증 (1차 - adjustNothing 방식)
- 명령: `./gradlew :app:assembleDebug`
- 결과: BUILD SUCCESSFUL (16s, 46 tasks)
- 변경 파일: `AndroidManifest.xml` (adjustPan→adjustNothing), `LiveRoomActivity.kt` (API S→R)
- 비고: 실기기에서 효과 없음 → 되돌림
### 2026-03-29 빌드 검증 (2차 - 키보드 강제 갱신 방식)
- 명령: `./gradlew :app:assembleDebug`
- 결과: BUILD SUCCESSFUL (21s, 46 tasks)
- 변경 파일: `LiveRoomActivity.kt` (RTM/RTC 연결 완료 후 키보드 올렸다 내리기)
- 비고: 키보드가 화면을 위로 밀어올리는 것이 사용자에게 보임 → 눈속임 개선 필요
### 2026-03-29 빌드 검증 (3차 - 눈속임 개선)
- 명령: `./gradlew :app:assembleDebug`
- 결과: BUILD SUCCESSFUL (18s, 46 tasks)
- 변경 파일: `LiveRoomActivity.kt`
- 방식: adjustNothing 임시 전환 + 로딩 다이얼로그 뒤에서 키보드 트릭 수행