Compare commits

...

4 Commits

29 changed files with 1124 additions and 185 deletions

View File

@@ -14,6 +14,9 @@
<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 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

@@ -25,6 +25,7 @@ import kr.co.vividnext.sodalive.chat.character.detail.CharacterDetailActivity.Co
import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllActivity import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllActivity
import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllAdapter import kr.co.vividnext.sodalive.chat.character.newcharacters.NewCharactersAllAdapter
import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter import kr.co.vividnext.sodalive.chat.character.recent.RecentCharacterAdapter
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.FragmentCharacterTabBinding import kr.co.vividnext.sodalive.databinding.FragmentCharacterTabBinding
@@ -33,6 +34,7 @@ import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.mypage.MyPageViewModel import kr.co.vividnext.sodalive.mypage.MyPageViewModel
import kr.co.vividnext.sodalive.mypage.auth.Auth import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
@@ -375,7 +377,8 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
return return
} }
if (!SharedPreferenceManager.isAuth) { val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = requireActivity(), activity = requireActivity(),
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -390,6 +393,15 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
return return
} }
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
onAuthed() onAuthed()
} }

View File

@@ -7,6 +7,7 @@ object Constants {
const val PREF_EMAIL = "pref_email" const val PREF_EMAIL = "pref_email"
const val PREF_USER_ID = "pref_user_id" const val PREF_USER_ID = "pref_user_id"
const val PREF_IS_ADULT = "pref_is_adult" const val PREF_IS_ADULT = "pref_is_adult"
const val PREF_COUNTRY_CODE = "pref_country_code"
const val PREF_NICKNAME = "pref_nickname" const val PREF_NICKNAME = "pref_nickname"
const val PREF_USER_ROLE = "pref_user_role" const val PREF_USER_ROLE = "pref_user_role"
const val PREF_NO_CHAT_ROOM = "pref_no_chat" const val PREF_NO_CHAT_ROOM = "pref_no_chat"
@@ -81,6 +82,7 @@ object Constants {
const val EXTRA_AUDIO_CONTENT_PLAYLIST = "extra_audio_content_playlist" const val EXTRA_AUDIO_CONTENT_PLAYLIST = "extra_audio_content_playlist"
const val EXTRA_PLAYLIST_SEGMENT_LOOP_IMAGE = "extra_playlist_segment_loop_image" const val EXTRA_PLAYLIST_SEGMENT_LOOP_IMAGE = "extra_playlist_segment_loop_image"
const val EXTRA_IS_SHOW_SECRET = "extra_is_show_secret" const val EXTRA_IS_SHOW_SECRET = "extra_is_show_secret"
const val EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE = "extra_show_sensitive_content_guide"
const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2 const val LIVE_SERVICE_NOTIFICATION_ID: Int = 2
const val ACTION_AUDIO_CONTENT_RECEIVER = "soda_live_action_content_receiver" const val ACTION_AUDIO_CONTENT_RECEIVER = "soda_live_action_content_receiver"

View File

@@ -220,6 +220,12 @@ object SharedPreferenceManager {
setPreference(Constants.PREF_IS_ADULT, value) setPreference(Constants.PREF_IS_ADULT, value)
} }
var countryCode: String
get() = getPreference(Constants.PREF_COUNTRY_CODE, "KR")
set(value) {
setPreference(Constants.PREF_COUNTRY_CODE, value)
}
var isAuditionNotification: Boolean var isAuditionNotification: Boolean
get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false) get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false)
set(value) { set(value) {
@@ -227,7 +233,7 @@ object SharedPreferenceManager {
} }
var isAdultContentVisible: Boolean var isAdultContentVisible: Boolean
get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true) get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, false)
set(value) { set(value) {
setPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, value) setPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, value)
} }

View File

@@ -331,7 +331,7 @@ class AppDI(private val context: Context, isDebugMode: Boolean) {
viewModel { EventViewModel(get()) } viewModel { EventViewModel(get()) }
viewModel { NotificationSettingsViewModel(get()) } viewModel { NotificationSettingsViewModel(get()) }
viewModel { NotificationReceiveSettingsViewModel(get(), get()) } viewModel { NotificationReceiveSettingsViewModel(get(), get()) }
viewModel { ContentSettingsViewModel() } viewModel { ContentSettingsViewModel(get()) }
viewModel { SettingsViewModel(get(), get()) } viewModel { SettingsViewModel(get(), get()) }
viewModel { SeriesDetailViewModel(get(), get()) } viewModel { SeriesDetailViewModel(get(), get()) }
viewModel { SeriesListAllViewModel(get()) } viewModel { SeriesListAllViewModel(get()) }

View File

@@ -61,6 +61,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.event.EventDetailActivity import kr.co.vividnext.sodalive.settings.event.EventDetailActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper import kr.co.vividnext.sodalive.settings.language.LocaleHelper
@@ -1339,7 +1340,8 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (!SharedPreferenceManager.isAuth) { val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = requireActivity(), activity = requireActivity(),
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -1354,6 +1356,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
onAuthed() onAuthed()
} }
@@ -1363,19 +1374,31 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
SodaDialog( val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
activity = requireActivity(), if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
layoutInflater = layoutInflater, SodaDialog(
title = getString(R.string.auth_title), activity = requireActivity(),
desc = getString(R.string.auth_desc_live), layoutInflater = layoutInflater,
confirmButtonTitle = getString(R.string.auth_go), title = getString(R.string.auth_title),
confirmButtonClick = { startAuthFlow() }, desc = getString(R.string.auth_desc_live),
cancelButtonTitle = getString(R.string.cancel), confirmButtonTitle = getString(R.string.auth_go),
cancelButtonClick = {}, confirmButtonClick = { startAuthFlow() },
descGravity = Gravity.CENTER cancelButtonTitle = getString(R.string.cancel),
).show(screenWidth) cancelButtonClick = {},
return descGravity = Gravity.CENTER
).show(screenWidth)
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
} }
onAuthed() onAuthed()

View File

@@ -65,6 +65,7 @@ import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity import kr.co.vividnext.sodalive.mypage.can.charge.CanChargeActivity
import kr.co.vividnext.sodalive.search.SearchActivity import kr.co.vividnext.sodalive.search.SearchActivity
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.settings.notification.MemberRole import kr.co.vividnext.sodalive.settings.notification.MemberRole
@@ -862,19 +863,31 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
SodaDialog( val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
activity = requireActivity(), if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
layoutInflater = layoutInflater, SodaDialog(
title = getString(R.string.auth_title), activity = requireActivity(),
desc = getString(R.string.auth_desc_live), layoutInflater = layoutInflater,
confirmButtonTitle = getString(R.string.auth_go), title = getString(R.string.auth_title),
confirmButtonClick = { startAuthFlow() }, desc = getString(R.string.auth_desc_live),
cancelButtonTitle = getString(R.string.cancel), confirmButtonTitle = getString(R.string.auth_go),
cancelButtonClick = {}, confirmButtonClick = { startAuthFlow() },
descGravity = Gravity.CENTER cancelButtonTitle = getString(R.string.cancel),
).show(screenWidth) cancelButtonClick = {},
return descGravity = Gravity.CENTER
).show(screenWidth)
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
} }
onAuthed() onAuthed()

View File

@@ -31,6 +31,7 @@ import kr.co.vividnext.sodalive.mypage.MyPageViewModel
import kr.co.vividnext.sodalive.mypage.auth.Auth import kr.co.vividnext.sodalive.mypage.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse import kr.co.vividnext.sodalive.mypage.auth.BootpayResponse
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.settings.language.LanguageManager import kr.co.vividnext.sodalive.settings.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper import kr.co.vividnext.sodalive.settings.language.LocaleHelper
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
@@ -119,19 +120,31 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
SodaDialog( val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
activity = this, if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
layoutInflater = layoutInflater, SodaDialog(
title = getString(R.string.auth_title), activity = this,
desc = getString(R.string.auth_desc_live), layoutInflater = layoutInflater,
confirmButtonTitle = getString(R.string.auth_go), title = getString(R.string.auth_title),
confirmButtonClick = { startAuthFlow() }, desc = getString(R.string.auth_desc_live),
cancelButtonTitle = getString(R.string.cancel), confirmButtonTitle = getString(R.string.auth_go),
cancelButtonClick = {}, confirmButtonClick = { startAuthFlow() },
descGravity = Gravity.CENTER cancelButtonTitle = getString(R.string.cancel),
).show(screenWidth) cancelButtonClick = {},
return descGravity = Gravity.CENTER
).show(screenWidth)
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(applicationContext, ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
} }
onAuthed() onAuthed()

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
@@ -120,11 +124,13 @@ import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.report.ProfileReportDialog import kr.co.vividnext.sodalive.report.ProfileReportDialog
import kr.co.vividnext.sodalive.report.ReportType 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.ContentSettingsActivity
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 +171,15 @@ 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 isScreenRecordingCallbackRegistered = false
// API 레벨별 콜백 인스턴스를 재사용해 등록/해제 짝을 보장한다.
private var screenRecordingCallback: Any? = null
private var isHost = false private var isHost = false
private var isAvailableLikeHeart = false private var isAvailableLikeHeart = false
@@ -352,6 +367,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 +386,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 +414,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 +437,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 +668,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, _ ->
@@ -1039,17 +1054,35 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
} }
viewModel.changeIsAdultLiveData.observe(this) { viewModel.changeIsAdultLiveData.observe(this) {
if (it && !SharedPreferenceManager.isAuth) { if (it) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
val shouldBlockByAuth = isKoreanCountry && !SharedPreferenceManager.isAuth
val shouldBlockBySensitiveContent = !isKoreanCountry && !SharedPreferenceManager.isAdultContentVisible
if (!shouldBlockByAuth && !shouldBlockBySensitiveContent) {
return@observe
}
agora.muteAllRemoteAudioStreams(true) agora.muteAllRemoteAudioStreams(true)
binding.rvChat.visibility = View.INVISIBLE binding.rvChat.visibility = View.INVISIBLE
SodaDialog(
this@LiveRoomActivity, if (shouldBlockBySensitiveContent) {
layoutInflater, showToast(getString(R.string.screen_content_settings_sensitive_content_guide))
getString(R.string.screen_live_room_age_limit_title), startActivity(
getString(R.string.screen_live_room_age_limit_message), Intent(applicationContext, ContentSettingsActivity::class.java).apply {
getString(R.string.screen_live_room_ok), putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
{ finish() } }
).show(screenWidth) )
finish()
} else {
SodaDialog(
this@LiveRoomActivity,
layoutInflater,
getString(R.string.screen_live_room_age_limit_title),
getString(R.string.screen_live_room_age_limit_message),
getString(R.string.screen_live_room_ok),
{ finish() }
).show(screenWidth)
}
} }
} }
@@ -1518,7 +1551,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 +1580,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 +1595,155 @@ 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() {
registerScreenRecordingCallback()
}
@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 상태를 정리한다.
unregisterScreenRecordingCallback()
clearCapturePrivacyMuteState()
}
@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 onScreenRecordingStateChanged(isRecording: Boolean) {
// 시스템이 알려준 녹화 가시 상태를 강제 mute 계산에 반영한다.
isScreenRecordingActive = isRecording
syncCapturePrivacyMuteState()
}
private fun clearCapturePrivacyMuteState() {
// 라이프사이클 해제 시 캡처 기반 플래그를 모두 초기화해 원복을 보장한다.
isScreenRecordingActive = false
syncCapturePrivacyMuteState()
}
private fun syncCapturePrivacyMuteState() {
val shouldMute = isScreenRecordingActive
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 +1977,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")

View File

@@ -250,7 +250,14 @@ class LiveRoomViewModel(
getTotalDonationCan(roomId = roomId) getTotalDonationCan(roomId = roomId)
getTotalHeart(roomId = roomId) getTotalHeart(roomId = roomId)
if (it.data.isAdult && !SharedPreferenceManager.isAuth) { val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
val isAdultContentBlocked = if (isKoreanCountry) {
!SharedPreferenceManager.isAuth
} else {
!SharedPreferenceManager.isAdultContentVisible
}
if (it.data.isAdult && isAdultContentBlocked) {
_changeIsAdultLiveData.value = true _changeIsAdultLiveData.value = true
} }

View File

@@ -10,6 +10,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.URLUtil import android.webkit.URLUtil
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
@@ -44,6 +45,11 @@ class LiveRoomDetailFragment(
private val onClickCancel: () -> Unit private val onClickCancel: () -> Unit
) : BottomSheetDialogFragment() { ) : BottomSheetDialogFragment() {
private data class SnsItem(
val url: String,
val iconResId: Int
)
private val viewModel: LiveRoomDetailViewModel by inject() private val viewModel: LiveRoomDetailViewModel by inject()
private lateinit var binding: FragmentLiveRoomDetailBinding private lateinit var binding: FragmentLiveRoomDetailBinding
@@ -273,41 +279,7 @@ class LiveRoomDetailFragment(
transformations(CircleCropTransformation()) transformations(CircleCropTransformation())
} }
if ( bindManagerSnsItems(manager)
manager.kakaoOpenChatUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.kakaoOpenChatUrl)
) {
binding.ivManagerOpenChat.visibility = View.GONE
} else {
binding.ivManagerOpenChat.visibility = View.VISIBLE
binding.ivManagerOpenChat.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, manager.kakaoOpenChatUrl.toUri()))
}
}
if (
manager.instagramUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.instagramUrl)
) {
binding.ivManagerInstagram.visibility = View.GONE
} else {
binding.ivManagerInstagram.visibility = View.VISIBLE
binding.ivManagerInstagram.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, manager.instagramUrl.toUri()))
}
}
if (
manager.youtubeUrl.isNullOrBlank() ||
!URLUtil.isValidUrl(manager.youtubeUrl)
) {
binding.ivManagerYoutube.visibility = View.GONE
} else {
binding.ivManagerYoutube.visibility = View.VISIBLE
binding.ivManagerYoutube.setOnClickListener {
startActivity(Intent(Intent.ACTION_VIEW, manager.youtubeUrl.toUri()))
}
}
if (manager.isCreator) { if (manager.isCreator) {
binding.tvManagerProfile.visibility = View.VISIBLE binding.tvManagerProfile.visibility = View.VISIBLE
@@ -321,6 +293,66 @@ class LiveRoomDetailFragment(
} }
} }
private fun bindManagerSnsItems(manager: GetRoomDetailManager) {
val snsItems = listOf(
SnsItem(
url = manager.youtubeUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_youtube
),
SnsItem(
url = manager.instagramUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_instagram
),
SnsItem(
url = manager.xUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_x
),
SnsItem(
url = manager.fancimmUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_fancimm
),
SnsItem(
url = manager.kakaoOpenChatUrl?.trim().orEmpty(),
iconResId = R.drawable.ic_sns_kakao
)
).filter { item ->
item.url.isNotBlank() && URLUtil.isValidUrl(item.url)
}
binding.llManagerSnsIcons.removeAllViews()
binding.llManagerSnsIcons.visibility = if (snsItems.isEmpty()) {
View.GONE
} else {
View.VISIBLE
}
snsItems.forEachIndexed { index, item ->
val imageView = ImageView(requireContext()).apply {
setImageResource(item.iconResId)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply {
if (index > 0) {
marginStart = 8.dpToPx().toInt()
}
}
setOnClickListener {
openUrl(item.url)
}
}
binding.llManagerSnsIcons.addView(imageView)
}
}
private fun openUrl(url: String) {
val intent = Intent(Intent.ACTION_VIEW, url.toUri())
if (intent.resolveActivity(requireActivity().packageManager) != null) {
startActivity(intent)
}
}
private fun setParticipantUserSummary(participatingUsers: List<GetRoomDetailUser>) { private fun setParticipantUserSummary(participatingUsers: List<GetRoomDetailUser>) {
val userCount = if (participatingUsers.size > 10) { val userCount = if (participatingUsers.size > 10) {
10 10

View File

@@ -103,6 +103,9 @@ class MainViewModel(
SharedPreferenceManager.point = data.point SharedPreferenceManager.point = data.point
SharedPreferenceManager.role = data.role.name SharedPreferenceManager.role = data.role.name
SharedPreferenceManager.isAuth = data.isAuth SharedPreferenceManager.isAuth = data.isAuth
SharedPreferenceManager.countryCode = data.countryCode.ifBlank { "KR" }
SharedPreferenceManager.isAdultContentVisible = data.isAdultContentVisible
SharedPreferenceManager.contentPreference = data.contentType.ordinal
SharedPreferenceManager.isAuditionNotification = SharedPreferenceManager.isAuditionNotification =
data.auditionNotice ?: false data.auditionNotice ?: false
if ( if (

View File

@@ -380,13 +380,30 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
} }
viewModel.myPageLiveData.observe(viewLifecycleOwner) { viewModel.myPageLiveData.observe(viewLifecycleOwner) {
if (it.isAuth) { val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verified)
)
if (isKoreanUser) {
binding.btnIdentityVerification.root.visibility = View.VISIBLE
if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verified)
)
} else {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verification)
) {
showAuthDialog()
}
}
} else {
binding.btnIdentityVerification.root.visibility = View.GONE
}
if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton( FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCoupon.root, buttonView = binding.btnCoupon.root,
iconRes = R.drawable.ic_my_coupon, iconRes = R.drawable.ic_my_coupon,
@@ -400,14 +417,6 @@ class MyPageFragment : BaseFragment<FragmentMyBinding>(FragmentMyBinding::inflat
) )
} }
} else { } else {
FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verification)
) {
showAuthDialog()
}
FunctionButtonHelper.setupFunctionButton( FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnCoupon.root, buttonView = binding.btnCoupon.root,
iconRes = R.drawable.ic_my_coupon, iconRes = R.drawable.ic_my_coupon,

View File

@@ -3,9 +3,13 @@ package kr.co.vividnext.sodalive.settings
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback import androidx.activity.OnBackPressedCallback
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseActivity import kr.co.vividnext.sodalive.base.BaseActivity
import kr.co.vividnext.sodalive.base.SodaDialog
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.LoadingDialog
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityContentSettingsBinding import kr.co.vividnext.sodalive.databinding.ActivityContentSettingsBinding
import kr.co.vividnext.sodalive.splash.SplashActivity import kr.co.vividnext.sodalive.splash.SplashActivity
@@ -16,37 +20,74 @@ class ContentSettingsActivity : BaseActivity<ActivityContentSettingsBinding>(
) { ) {
private val viewModel: ContentSettingsViewModel by inject() private val viewModel: ContentSettingsViewModel by inject()
private lateinit var loadingDialog: LoadingDialog
private val sensitiveContentConfirmDialog: SodaDialog by lazy {
SodaDialog(
activity = this,
layoutInflater = layoutInflater,
title = getString(R.string.dialog_sensitive_content_enable_title),
desc = getString(R.string.dialog_sensitive_content_enable_message),
confirmButtonTitle = getString(R.string.screen_live_room_yes),
confirmButtonClick = { viewModel.toggleAdultContentVisible() },
cancelButtonTitle = getString(R.string.screen_live_room_no),
cancelButtonClick = {}
)
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
bindData() bindData()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { onBackPressedDispatcher.addCallback(
override fun handleOnBackPressed() { this,
handleFinish() object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
handleFinish()
}
} }
}) )
} }
override fun setupView() { override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = getString(R.string.screen_content_settings_title) binding.toolbar.tvBack.text = getString(R.string.screen_content_settings_title)
binding.toolbar.tvBack.setOnClickListener { handleFinish() } binding.toolbar.tvBack.setOnClickListener { handleFinish() }
if (intent.getBooleanExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, false)) {
Toast.makeText(
applicationContext,
getString(R.string.screen_content_settings_sensitive_content_guide),
Toast.LENGTH_LONG
).show()
}
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
val canControlSensitiveContent = if (isKoreanCountry) {
SharedPreferenceManager.isAuth
} else {
true
}
// 본인 인증 체크 // 본인 인증 체크
if (SharedPreferenceManager.isAuth) { if (canControlSensitiveContent) {
binding.llAdultContentVisible.visibility = View.VISIBLE binding.llAdultContentVisible.visibility = View.VISIBLE
// 19금 콘텐츠 보기 체크 // 19금 콘텐츠 보기 체크
if (SharedPreferenceManager.isAdultContentVisible) { if (SharedPreferenceManager.isAdultContentVisible) {
binding.llAdultContentPreference.visibility = View.VISIBLE binding.llAdultContentPreference.visibility = View.VISIBLE
} else { } else {
binding.llAdultContentPreference.visibility = View.GONE binding.llAdultContentPreference.visibility = View.GONE
} }
// 19금 콘텐츠 보기 스위치 액션 // 19금 콘텐츠 보기 스위치 액션
binding.ivAdultContentVisible.setOnClickListener { binding.ivAdultContentVisible.setOnClickListener {
viewModel.toggleAdultContentVisible() val isAdultContentVisible = viewModel.isAdultContentVisible.value == true
if (isAdultContentVisible) {
viewModel.toggleAdultContentVisible()
} else {
sensitiveContentConfirmDialog.show(screenWidth)
}
} }
binding.tvContentAll.setOnClickListener { binding.tvContentAll.setOnClickListener {
@@ -88,6 +129,21 @@ class ContentSettingsActivity : BaseActivity<ActivityContentSettingsBinding>(
else -> {} else -> {}
} }
} }
viewModel.isLoading.observe(this) {
if (it) {
loadingDialog.show(screenWidth)
} else {
loadingDialog.dismiss()
}
}
viewModel.toastLiveData.observe(this) {
val text = it?.message ?: it?.resId?.let { resId -> getString(resId) }
text?.let { message ->
Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show()
}
}
} }
private fun handleFinish() { private fun handleFinish() {

View File

@@ -1,39 +1,201 @@
package kr.co.vividnext.sodalive.settings package kr.co.vividnext.sodalive.settings
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import com.orhanobut.logger.Logger
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.schedulers.Schedulers
import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.base.BaseViewModel import kr.co.vividnext.sodalive.base.BaseViewModel
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.user.UserRepository
class ContentSettingsViewModel : BaseViewModel() { class ContentSettingsViewModel(
private var _isAdultContentVisible = MutableLiveData( private val userRepository: UserRepository
SharedPreferenceManager.isAdultContentVisible ) : BaseViewModel() {
private data class PreferenceState(
val isAdultContentVisible: Boolean,
val contentType: ContentType
) )
private val handler = Handler(Looper.getMainLooper())
private val initialState = getCurrentPreferenceState()
private var confirmedState = initialState
private var isRequestInFlight = false
private var pendingState: PreferenceState? = null
private var syncRunnable: Runnable? = null
private val _isAdultContentVisible = MutableLiveData(initialState.isAdultContentVisible)
val isAdultContentVisible: LiveData<Boolean> val isAdultContentVisible: LiveData<Boolean>
get() = _isAdultContentVisible get() = _isAdultContentVisible
private var _adultContentPreference = MutableLiveData( private val _adultContentPreference = MutableLiveData(initialState.contentType)
ContentType.values()[SharedPreferenceManager.contentPreference]
)
val adultContentPreference: LiveData<ContentType> val adultContentPreference: LiveData<ContentType>
get() = _adultContentPreference get() = _adultContentPreference
private val _isLoading = MutableLiveData(false)
val isLoading: LiveData<Boolean>
get() = _isLoading
private val _toastLiveData = MutableLiveData<ToastMessage?>()
val toastLiveData: LiveData<ToastMessage?>
get() = _toastLiveData
var isChangedAdultContentVisible = false var isChangedAdultContentVisible = false
private set
fun toggleAdultContentVisible() { fun toggleAdultContentVisible() {
val adultContentVisible = SharedPreferenceManager.isAdultContentVisible val currentState = getCurrentPreferenceState()
_isAdultContentVisible.value = !adultContentVisible val nextState = PreferenceState(
SharedPreferenceManager.isAdultContentVisible = !adultContentVisible isAdultContentVisible = !currentState.isAdultContentVisible,
isChangedAdultContentVisible = true contentType = if (currentState.isAdultContentVisible) {
ContentType.ALL
} else {
currentState.contentType
}
)
if (adultContentVisible) { applyLocalState(nextState)
SharedPreferenceManager.contentPreference = ContentType.ALL.ordinal queueLatestStateForSync()
}
} }
fun setAdultContentPreference(adultContentPreference: ContentType) { fun setAdultContentPreference(adultContentPreference: ContentType) {
_adultContentPreference.value = adultContentPreference val currentState = getCurrentPreferenceState()
SharedPreferenceManager.contentPreference = adultContentPreference.ordinal if (currentState.contentType == adultContentPreference) {
isChangedAdultContentVisible = true return
}
applyLocalState(
currentState.copy(contentType = adultContentPreference)
)
queueLatestStateForSync()
}
private fun queueLatestStateForSync() {
pendingState = getCurrentPreferenceState()
syncRunnable?.let { handler.removeCallbacks(it) }
syncRunnable = Runnable {
flushPendingState()
}.also {
handler.postDelayed(it, CONTENT_PREFERENCE_DEBOUNCE_DELAY_MS)
}
}
private fun flushPendingState() {
if (isRequestInFlight) {
return
}
val targetState = pendingState ?: return
if (targetState == confirmedState) {
pendingState = null
return
}
pendingState = null
syncContentPreference(targetState)
}
private fun syncContentPreference(targetState: PreferenceState) {
val request = UpdateContentPreferenceRequest(
isAdultContentVisible = if (targetState.isAdultContentVisible != confirmedState.isAdultContentVisible) {
targetState.isAdultContentVisible
} else {
null
},
contentType = if (targetState.contentType != confirmedState.contentType) {
targetState.contentType
} else {
null
}
)
if (request.isAdultContentVisible == null && request.contentType == null) {
return
}
isRequestInFlight = true
_isLoading.value = true
compositeDisposable.add(
userRepository.updateContentPreference(
request = request,
token = "Bearer ${SharedPreferenceManager.token}"
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
isRequestInFlight = false
_isLoading.value = false
if (response.success) {
val syncedState = response.data?.let {
PreferenceState(
isAdultContentVisible = it.isAdultContentVisible,
contentType = it.contentType
)
} ?: targetState
confirmedState = syncedState
if (pendingState == null) {
applyLocalState(syncedState)
}
flushPendingState()
} else {
rollbackToConfirmedState(response.message)
}
},
{
isRequestInFlight = false
_isLoading.value = false
it.message?.let { message -> Logger.e(message) }
rollbackToConfirmedState(null)
}
)
)
}
private fun rollbackToConfirmedState(errorMessage: String?) {
pendingState = null
syncRunnable?.let { handler.removeCallbacks(it) }
syncRunnable = null
applyLocalState(confirmedState)
_toastLiveData.postValue(
errorMessage?.let { ToastMessage(message = it) } ?: ToastMessage(resId = R.string.retry)
)
}
private fun applyLocalState(state: PreferenceState) {
_isAdultContentVisible.value = state.isAdultContentVisible
_adultContentPreference.value = state.contentType
SharedPreferenceManager.isAdultContentVisible = state.isAdultContentVisible
SharedPreferenceManager.contentPreference = state.contentType.ordinal
isChangedAdultContentVisible = state != initialState
}
private fun getCurrentPreferenceState(): PreferenceState {
val localContentType = ContentType.entries.getOrNull(SharedPreferenceManager.contentPreference)
?: ContentType.ALL
return PreferenceState(
isAdultContentVisible = SharedPreferenceManager.isAdultContentVisible,
contentType = localContentType
)
}
override fun onCleared() {
syncRunnable?.let { handler.removeCallbacks(it) }
syncRunnable = null
super.onCleared()
}
companion object {
private const val CONTENT_PREFERENCE_DEBOUNCE_DELAY_MS = 400L
} }
} }

View File

@@ -84,7 +84,8 @@ class SettingsActivity : BaseActivity<ActivitySettingsBinding>(ActivitySettingsB
) )
} }
if (SharedPreferenceManager.isAuth) { val isNotKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } != "KR"
if (SharedPreferenceManager.isAuth || isNotKoreanCountry) {
binding.dividerContentSettings.visibility = View.VISIBLE binding.dividerContentSettings.visibility = View.VISIBLE
binding.rlContentSettings.visibility = View.VISIBLE binding.rlContentSettings.visibility = View.VISIBLE
binding.rlContentSettings.setOnClickListener { binding.rlContentSettings.setOnClickListener {

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.settings
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class UpdateContentPreferenceRequest(
@SerializedName("isAdultContentVisible") val isAdultContentVisible: Boolean? = null,
@SerializedName("contentType") val contentType: ContentType? = null
)

View File

@@ -0,0 +1,10 @@
package kr.co.vividnext.sodalive.settings
import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName
@Keep
data class UpdateContentPreferenceResponse(
@SerializedName("isAdultContentVisible") val isAdultContentVisible: Boolean,
@SerializedName("contentType") val contentType: ContentType
)

View File

@@ -2,6 +2,7 @@ package kr.co.vividnext.sodalive.settings.notification
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.settings.ContentType
@Keep @Keep
data class GetMemberInfoResponse( data class GetMemberInfoResponse(
@@ -18,7 +19,13 @@ data class GetMemberInfoResponse(
@SerializedName("followingChannelUploadContentNotice") @SerializedName("followingChannelUploadContentNotice")
val followingChannelUploadContentNotice: Boolean?, val followingChannelUploadContentNotice: Boolean?,
@SerializedName("auditionNotice") @SerializedName("auditionNotice")
val auditionNotice: Boolean? val auditionNotice: Boolean?,
@SerializedName("countryCode")
val countryCode: String,
@SerializedName("isAdultContentVisible")
val isAdultContentVisible: Boolean,
@SerializedName("contentType")
val contentType: ContentType
) )
enum class MemberRole { enum class MemberRole {
@@ -26,5 +33,5 @@ enum class MemberRole {
USER, USER,
@SerializedName("CREATOR") @SerializedName("CREATOR")
CREATOR, CREATOR
} }

View File

@@ -13,6 +13,8 @@ import kr.co.vividnext.sodalive.mypage.block.GetBlockedMemberListResponse
import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse
import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest
import kr.co.vividnext.sodalive.mypage.profile.nickname.GetChangeNicknamePriceResponse import kr.co.vividnext.sodalive.mypage.profile.nickname.GetChangeNicknamePriceResponse
import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceRequest
import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceResponse
import kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponse import kr.co.vividnext.sodalive.settings.notification.GetMemberInfoResponse
import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.settings.signout.SignOutRequest import kr.co.vividnext.sodalive.settings.signout.SignOutRequest
@@ -26,6 +28,7 @@ import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header import retrofit2.http.Header
import retrofit2.http.Multipart import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
import retrofit2.http.Part import retrofit2.http.Part
@@ -49,6 +52,12 @@ interface UserApi {
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String
): Single<ApiResponse<GetMemberInfoResponse>> ): Single<ApiResponse<GetMemberInfoResponse>>
@PATCH("/member/content-preference")
fun updateContentPreference(
@Body request: UpdateContentPreferenceRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<UpdateContentPreferenceResponse>>
@GET("/push/notification/categories") @GET("/push/notification/categories")
fun getPushNotificationCategories( fun getPushNotificationCategories(
@Header("Authorization") authHeader: String @Header("Authorization") authHeader: String

View File

@@ -11,6 +11,8 @@ import kr.co.vividnext.sodalive.main.PushTokenUpdateRequest
import kr.co.vividnext.sodalive.mypage.MyPageResponse import kr.co.vividnext.sodalive.mypage.MyPageResponse
import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse import kr.co.vividnext.sodalive.mypage.profile.ProfileResponse
import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest import kr.co.vividnext.sodalive.mypage.profile.ProfileUpdateRequest
import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceRequest
import kr.co.vividnext.sodalive.settings.UpdateContentPreferenceResponse
import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest import kr.co.vividnext.sodalive.settings.notification.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.settings.signout.SignOutRequest import kr.co.vividnext.sodalive.settings.signout.SignOutRequest
import kr.co.vividnext.sodalive.user.find_password.ForgotPasswordRequest import kr.co.vividnext.sodalive.user.find_password.ForgotPasswordRequest
@@ -38,6 +40,13 @@ class UserRepository(private val userApi: UserApi) {
fun getMemberInfo(token: String) = userApi.getMemberInfo(authHeader = token) fun getMemberInfo(token: String) = userApi.getMemberInfo(authHeader = token)
fun updateContentPreference(
request: UpdateContentPreferenceRequest,
token: String
): Single<ApiResponse<UpdateContentPreferenceResponse>> {
return userApi.updateContentPreference(request = request, authHeader = token)
}
fun getPushNotificationCategories(token: String): Single<ApiResponse<GetPushNotificationCategoryResponse>> { fun getPushNotificationCategories(token: String): Single<ApiResponse<GetPushNotificationCategoryResponse>> {
return userApi.getPushNotificationCategories(authHeader = token) return userApi.getPushNotificationCategories(authHeader = token)
} }

View File

@@ -248,33 +248,11 @@
android:orientation="vertical"> android:orientation="vertical">
<LinearLayout <LinearLayout
android:id="@+id/ll_manager_sns_icons"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal"> android:orientation="horizontal"
android:visibility="gone" />
<ImageView
android:id="@+id/iv_manager_open_chat"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@null"
android:src="@drawable/ic_website_blue" />
<ImageView
android:id="@+id/iv_manager_instagram"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:contentDescription="@null"
android:src="@drawable/ic_instagram_blue" />
<ImageView
android:id="@+id/iv_manager_youtube"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:contentDescription="@null"
android:src="@drawable/ic_youtube_play_blue" />
</LinearLayout>
<TextView <TextView
android:id="@+id/tv_manager_profile" android:id="@+id/tv_manager_profile"

View File

@@ -994,6 +994,9 @@
<string name="dialog_logout_all_message">Log out from all devices?</string> <string name="dialog_logout_all_message">Log out from all devices?</string>
<string name="screen_content_settings_title">Content viewing settings</string> <string name="screen_content_settings_title">Content viewing settings</string>
<string name="screen_content_settings_adult_toggle">Show sensitive content</string> <string name="screen_content_settings_adult_toggle">Show sensitive content</string>
<string name="dialog_sensitive_content_enable_title">Are you over 18?</string>
<string name="dialog_sensitive_content_enable_message">This content is available only to users aged 18 and over!</string>
<string name="screen_content_settings_sensitive_content_guide">To view sensitive content, turn on the Show sensitive content switch.</string>
<string name="screen_content_settings_all">All</string> <string name="screen_content_settings_all">All</string>
<string name="screen_content_settings_male">Male-oriented</string> <string name="screen_content_settings_male">Male-oriented</string>
<string name="screen_content_settings_female">Female-oriented</string> <string name="screen_content_settings_female">Female-oriented</string>

View File

@@ -994,6 +994,9 @@
<string name="dialog_logout_all_message">すべての端末からログアウトしますか?</string> <string name="dialog_logout_all_message">すべての端末からログアウトしますか?</string>
<string name="screen_content_settings_title">コンテンツ表示設定</string> <string name="screen_content_settings_title">コンテンツ表示設定</string>
<string name="screen_content_settings_adult_toggle">センシティブなコンテンツ表示</string> <string name="screen_content_settings_adult_toggle">センシティブなコンテンツ表示</string>
<string name="dialog_sensitive_content_enable_title">あなたは18歳以上ですか</string>
<string name="dialog_sensitive_content_enable_message">このコンテンツは18歳以上のみ利用できます</string>
<string name="screen_content_settings_sensitive_content_guide">センシティブなコンテンツを表示するには「センシティブなコンテンツ表示」スイッチをオンにしてください。</string>
<string name="screen_content_settings_all"></string> <string name="screen_content_settings_all"></string>
<string name="screen_content_settings_male">男性向け</string> <string name="screen_content_settings_male">男性向け</string>
<string name="screen_content_settings_female">女性向け</string> <string name="screen_content_settings_female">女性向け</string>

View File

@@ -993,6 +993,9 @@
<string name="dialog_logout_all_message">모든 기기에서 로그아웃 하시겠어요?</string> <string name="dialog_logout_all_message">모든 기기에서 로그아웃 하시겠어요?</string>
<string name="screen_content_settings_title">콘텐츠 보기 설정</string> <string name="screen_content_settings_title">콘텐츠 보기 설정</string>
<string name="screen_content_settings_adult_toggle">민감한 콘텐츠 보기</string> <string name="screen_content_settings_adult_toggle">민감한 콘텐츠 보기</string>
<string name="dialog_sensitive_content_enable_title">당신은 18세 이상입니까?</string>
<string name="dialog_sensitive_content_enable_message">해당 콘텐츠는 18세 이상만 이용이 가능합니다!</string>
<string name="screen_content_settings_sensitive_content_guide">민감한 콘텐츠를 보려면 민감한 콘텐츠 보기 스위치를 켜주세요.</string>
<string name="screen_content_settings_all">전체</string> <string name="screen_content_settings_all">전체</string>
<string name="screen_content_settings_male">남성향</string> <string name="screen_content_settings_male">남성향</string>
<string name="screen_content_settings_female">여성향</string> <string name="screen_content_settings_female">여성향</string>

View File

@@ -0,0 +1,39 @@
# 20260324 라이브룸 캡처/녹화 정합 개선
## 구현 체크리스트
- [x] 기존 구현에서 `FLAG_SECURE`와 스크린샷/녹화 콜백 결합 지점을 다시 점검한다. (QA: `LiveRoomActivity` 캡처 보안 관련 함수 흐름 확인)
- [x] `FLAG_SECURE` 유지 기준으로 dead path인 스크린샷 콜백 경로를 제거한다. (QA: `registerScreenCaptureCallback`/관련 상태 플래그 제거 확인)
- [x] 녹화 상태 기반 강제 mute 경로만 유지되도록 로직과 주석을 정리한다. (QA: `addScreenRecordingCallback` 경로 단일화 확인)
- [x] Manifest에서 불필요해진 스크린샷 감지 권한을 제거한다. (QA: `DETECT_SCREEN_CAPTURE` 선언 제거 확인)
- [x] 진단/테스트/빌드 및 수동 QA 결과를 누적 기록한다. (QA: 실행 명령과 결과 로그 확인)
## 검증 기록
- 2026-03-24
- 무엇: 후속 정합 개선 작업 계획 문서를 생성했다.
- 왜: 사용자 요청(더 나은/최신 방식 반영)에 맞춰 변경 범위와 완료 기준을 명확히 고정하기 위해서다.
- 어떻게: `docs/20260324_라이브룸캡처녹화정합개선.md` 파일을 생성하고 체크리스트/검증 섹션을 작성했다.
- 2026-03-24
- 무엇: 스크린샷 콜백 경로를 제거하고 녹화 기반 강제 mute 경로만 유지하도록 구현을 단순화했다.
- 왜: `FLAG_SECURE`를 유지하는 현재 전략에서 스크린샷 콜백 기반 강제 mute는 실질 동작하지 않는 dead path였기 때문이다.
- 어떻게:
- `app/src/main/java/kr/co/vividnext/sodalive/live/room/LiveRoomActivity.kt`에서 `registerScreenCaptureCallback`/`unregisterScreenCaptureCallback` 관련 함수, 스크린샷 상태 플래그(`isScreenshotMuteActive`) 및 타이머 상수/러너블을 제거했다.
- 강제 mute 계산을 `isScreenRecordingActive` 단일 상태로 정리했다.
- 2026-03-24
- 무엇: Manifest에서 불필요 권한을 제거했다.
- 왜: 스크린샷 콜백 경로를 제거했으므로 `DETECT_SCREEN_CAPTURE` 권한이 더 이상 필요하지 않다.
- 어떻게: `app/src/main/AndroidManifest.xml`에서 `<uses-permission android:name="android.permission.DETECT_SCREEN_CAPTURE" />` 선언을 삭제하고 `DETECT_SCREEN_RECORDING`만 유지했다.
- 2026-03-24
- 무엇: 설계 근거와 실행 검증을 완료했다.
- 왜: 변경이 실제로 dead path 제거 + 기존 보안 전략 유지를 만족하는지 객관적으로 확인하기 위해서다.
- 어떻게:
- 외부 근거 확인:
- Android `Activity.ScreenCaptureCallback` 문구 "This is not invoked if the activity window has WindowManager.LayoutParams.FLAG_SECURE set."를 근거로 스크린샷 콜백 비동작 조건을 재확인했다.
- `WindowManager` API 레퍼런스(`addScreenRecordingCallback`, `SCREEN_RECORDING_STATE_VISIBLE`, Added in API level 35)와 `WindowManager.LayoutParams.FLAG_SECURE` 레퍼런스를 확인해 녹화 상태 콜백/보안 플래그의 현재 문서 기준을 재점검했다.
- 정적 진단:
- `lsp_diagnostics` 결과: `.kt`/`.xml` LSP 서버 미구성으로 진단 불가, `.md` 파일은 diagnostics 없음.
- 빌드/테스트:
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`
- 수동 QA:
- 실행 명령: `python3` 검증 스크립트(FLAG_SECURE 유지, 스크린샷 API 제거, recording-only mute 계산, Manifest 권한 정합 확인)
- 결과: `MANUAL QA PASS: FLAG_SECURE 유지 + dead screenshot path 제거 + recording-only mute flow verified.`

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.`

View File

@@ -0,0 +1,22 @@
# 라이브 상세 SNS 아이콘 변경
## 구현 체크리스트
- [x] `LiveRoomDetailFragment`의 SNS 아이콘 렌더링을 `CreatorDetailDialog`와 동일한 아이콘 세트(`ic_sns_*`) 및 동적 노출 방식으로 변경한다.
- [x] `GetRoomDetailManager``youtubeUrl`, `instagramUrl`, `kakaoOpenChatUrl`, `fancimmUrl`, `xUrl`를 모두 SNS 표시 대상에 포함한다.
- [x] 레이아웃에서 매니저 SNS 아이콘 영역을 동적 추가 구조로 정리한다.
- [x] 수정 파일 진단, 테스트/빌드를 실행해 결과를 확인한다.
## 검증 기록
- LSP 진단 시도
- 명령: `lsp_diagnostics` (`LiveRoomDetailFragment.kt`, `fragment_live_room_detail.xml`)
- 결과: 현재 실행 환경에 Kotlin/XML LSP 서버가 설정되어 있지 않아 진단 불가(`No LSP server configured for extension: .kt/.xml`).
- 단위 테스트 + 빌드
- 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`
- 디바이스 설치 확인
- 명령: `./gradlew :app:installDebug`
- 결과: `Installed on 1 device.`
- 수동 QA 실행(실기기)
- 명령: `adb shell am start -n kr.co.vividnext.sodalive/.splash.SplashActivity`
- 결과: 앱 실행은 성공했으나 원격 설정에 의한 필수 업데이트 다이얼로그(`업데이트 후 사용가능합니다.`)가 표시되어 라이브 상세 진입 경로까지 진행 불가.
- 추가 확인: `adb shell am start -n kr.co.vividnext.sodalive/.live.now.all.LiveNowAllActivity` 실행 시 비-exported Activity로 `Permission Denial` 발생.

View File

@@ -0,0 +1,284 @@
# 20260326_멤버정보응답확장및콘텐츠보기설정동기화.md
## 개요
- `/member/info` 응답 스펙 확장(`countryCode`, `isAdultContentVisible`, `contentType`)을 앱 데이터 모델과 로컬 상태에 반영한다.
- 설정 화면의 `콘텐츠 보기 설정` 노출 조건을 기존 `SharedPreferenceManager.isAuth == true` 유지 + `countryCode != "KR"` 조건 추가로 확장한다.
- `ContentSettings`에서 값 변경 시 `/member/content-preference`(PATCH) API로 서버 동기화를 수행하고, API 호출 구간에 `LoadingDialog`를 표시한다.
- 빠른 연타 상황에서는 **RxJava debounce를 사용하지 않고** 마지막 상태만 서버로 전송되도록 처리한다.
## 요구사항 해석(확정)
- `/member/info` 응답 모델에 아래 필드를 추가한다.
- `countryCode: String`
- `isAdultContentVisible: Boolean`
- `contentType: ContentType`
- `/member/info`에서 수신한 `countryCode`, `isAdultContentVisible`, `contentType`을 로컬 저장소(`SharedPreferenceManager`)에 동기화한다.
- `SettingsActivity``콘텐츠 보기 설정` 메뉴 노출은 아래 중 하나라도 만족하면 표시한다.
- `SharedPreferenceManager.isAuth == true`
- `countryCode != "KR"`
- `ContentSettingsActivity`에서 성인 콘텐츠 토글/콘텐츠 타입 변경 시 `/member/content-preference` PATCH를 호출한다.
- `/member/content-preference` PATCH 요청은 실제로 변경된 필드만 전송할 수 있도록 request 파라미터를 optional로 지원한다.
- API 호출 동안 Loading UI를 노출한다.
- 연속 입력 시 debounce 처리하여 마지막 값만 전송한다.
- debounce 구현은 RxJava 연산자(`debounce`, `switchMap` 등)를 사용하지 않는다.
## 현재 구조 조사 요약
- `UserApi.getMemberInfo()`는 이미 `@GET("/member/info")`로 연결되어 있고 응답은 `GetMemberInfoResponse`를 사용한다.
- `MainViewModel.getMemberInfo()``SharedPreferenceManager.isAuth`를 갱신하는 현재 유일 경로다.
- `SettingsActivity`에서 `rlContentSettings` 노출 조건은 현재 `SharedPreferenceManager.isAuth` 단일 조건이다.
- `ContentSettingsActivity`/`ContentSettingsViewModel`은 현재 로컬 `SharedPreferenceManager`만 갱신하며 서버 PATCH 연동이 없다.
- `ContentSettingsViewModel`은 현재 `UserRepository`를 주입받지 않으며(`AppDI.kt`에서 `ContentSettingsViewModel()`), 로딩/오류 상태 LiveData도 없다.
## 설계 결정
- `GetMemberInfoResponse`를 확장하고, `MainViewModel.getMemberInfo()`에서 신규 필드를 `SharedPreferenceManager`에 동기화한다.
- 국가 코드 저장을 위해 `Constants.PREF_COUNTRY_CODE` + `SharedPreferenceManager.countryCode`를 추가한다.
- 설정 메뉴 노출 조건은 `isAuth || countryCode != "KR"`로 통합한다.
- `countryCode` 미존재/공백 시 기본값은 `"KR"`로 간주하여 기존 노출 정책을 깨지 않도록 한다.
- `/member/content-preference` PATCH API를 `UserApi`/`UserRepository`에 추가하고, 요청/응답 DTO를 분리한다.
- `ContentSettingsViewModel``UserRepository`를 주입해 서버 동기화 책임을 이동한다.
- debounce는 RxJava 없이 `Handler(Looper.getMainLooper()) + Runnable`로 구현한다.
- 입력마다 이전 Runnable을 취소하고 지연 전송을 재등록한다.
- 지연 종료 시점의 최신 상태(`isAdultContentVisible`, `contentType`)만 전송한다.
- API 진행 중 추가 변경이 들어오면 최신 상태를 대기열로 유지하고, 현재 요청 완료 직후 마지막 상태 1회만 추가 전송한다.
- `LoadingDialog`는 **실제 PATCH 요청 수행 구간**에만 표시한다(디바운스 대기 시간에는 미표시).
## 완료 기준 (Acceptance Criteria)
- [x] AC1: `/member/info` 응답 확장 필드 3개가 모델에 반영되고, 로컬 저장소 동기화까지 정상 동작한다.
- [x] AC2: 설정 화면에서 `isAuth == true` 또는 `countryCode != "KR"`이면 `콘텐츠 보기 설정` 메뉴가 노출된다.
- [x] AC3: `ContentSettings`의 토글/타입 변경이 `/member/content-preference` PATCH로 서버에 전달된다.
- [x] AC4: PATCH 요청 수행 중 `LoadingDialog`가 표시되고 완료/실패 시 정상 해제된다.
- [x] AC5: 연속 탭 시 마지막 상태만 서버로 전송된다.
- [x] AC6: debounce 구현에서 RxJava debounce 계열 연산자를 사용하지 않는다.
## 구현 체크리스트
### 1) `/member/info` 응답 모델 확장
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt`
- `countryCode`, `isAdultContentVisible`, `contentType` 필드 추가
- [x] `contentType`은 현재 로컬에서 사용하는 `ContentType` enum과 동일 타입으로 사용하도록 매핑 정합을 유지한다.
### 2) 로컬 저장소 키/상태 확장
- [x] `app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt`
- `PREF_COUNTRY_CODE` 상수 추가
- [x] `app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt`
- `countryCode: String` 프로퍼티 추가
- 필요 시 멤버 정보 기반 기본값 동기화 로직 정의
- [x] `app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt`
- `getMemberInfo()` 성공 시 `countryCode`, `isAdultContentVisible`, `contentType`을 각각 `SharedPreferenceManager.countryCode`, `SharedPreferenceManager.isAdultContentVisible`, `SharedPreferenceManager.contentPreference`로 동기화한다.
### 3) 설정 메뉴 노출 조건 확장
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt`
- `rlContentSettings` 표시 조건을 `isAuth || countryCode != "KR"`로 변경
- 기존 숨김/표시 및 클릭 연결 흐름 유지
### 4) 콘텐츠 설정 PATCH API 추가
- [x] `app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt`
- `@PATCH("/member/content-preference")` API 정의 추가
- [x] `app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt`
- content preference 업데이트 메서드 추가
- [x] 신규 DTO 파일 추가
- 요청: `UpdateContentPreferenceRequest` (`isAdultContentVisible`, `contentType`)
- 응답: `UpdateContentPreferenceResponse` (`isAdultContentVisible`, `contentType`)
### 5) ContentSettings 서버 동기화 + Non-Rx debounce
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt`
- `UserRepository` 주입으로 생성자 변경
- 로딩/오류 상태 LiveData 추가
- 로컬 상태 반영 후 debounce 스케줄링 함수 추가(Handler/Runnable)
- in-flight + pending 상태를 분리해 마지막 상태 1회 전송 보장
- `onCleared()`에서 callback 정리
- [x] `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
- `viewModel { ContentSettingsViewModel(get()) }`로 DI 갱신
### 6) ContentSettings 화면 로딩 UI 반영
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt`
- `LoadingDialog` 초기화 및 `viewModel.isLoading` 관찰
- 요청 중 표시/요청 완료 해제 처리
### 7) 검증
- [ ] `lsp_diagnostics`로 수정 파일 신규 오류 확인
- [x] `./gradlew :app:testDebugUnitTest` 실행
- [x] `./gradlew :app:assembleDebug` 실행
- [ ] 수동 QA
- 인증 사용자 + 비인증/해외 국가 코드 조합별 메뉴 노출 확인
- 콘텐츠 설정 연타 시 네트워크 요청이 마지막 값 위주로 수렴되는지 확인
- 요청 중 LoadingDialog 표시/해제 확인
### 8) 추가 요구사항 반영 (2026-03-27)
- [x] `app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt`
- 본인인증/인증완료 아이템(`btnIdentityVerification`)을 `countryCode == "KR"`일 때만 노출
- 비KR에서는 본인인증 버튼을 숨김 처리
### 9) 콘텐츠 설정 PATCH optional request 반영 (2026-03-27)
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt`
- `isAdultContentVisible`, `contentType`를 nullable optional 파라미터로 변경
- [x] `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt`
- 변경된 필드만 request에 담아 PATCH 호출하도록 분기
### 10) 캐릭터 상세 진입 인증 체크 국가 분기 반영 (2026-03-27)
- [x] `app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt`
- 캐릭터 상세 진입 전 `ensureLoginAndAuth`를 KR/비KR 분기로 조정
- [x] `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt`
- 캐릭터 상세 진입 전 `ensureLoginAndAuth`를 KR/비KR 분기로 조정
### 11) KR 인증완료 + 민감 콘텐츠 OFF 케이스 동작 일치화 (2026-03-27)
- [x] `app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt`
- `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 상태라도 `isAdultContentVisible == false`면 설정 가이드로 이동
- `ensureLoginAndAuth`(캐릭터 상세)에서 KR 인증완료 상태라도 `isAdultContentVisible == false`면 설정 가이드로 이동
- [x] `app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt`
- `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리
- [x] `app/src/main/java/kr/co/vividnext/sodalive/live/now/all/LiveNowAllActivity.kt`
- `ensureLoginAndAdultAuth`(19금 라이브 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리
- [x] `app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt`
- `ensureLoginAndAuth`(캐릭터 상세)에서 KR 인증완료 + 민감 콘텐츠 OFF를 비KR OFF와 동일 처리
## 영향 파일(예상)
### 필수
- `app/src/main/java/kr/co/vividnext/sodalive/settings/notification/GetMemberInfoResponse.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/user/UserApi.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/user/UserRepository.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/common/Constants.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/main/MainViewModel.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/settings/SettingsActivity.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsActivity.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/di/AppDI.kt`
### 신규 파일(예상)
- `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt`
- `app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceResponse.kt`
## 리스크 및 확인사항
- `contentType`은 현재 로컬에서 사용하는 `ContentType`과 동일 타입을 사용한다.
- 멤버 정보 API 호출 시점은 앱 실행 후 3번째 실행 흐름으로, 메뉴 노출 조건 반영 타이밍상 문제는 없는 것으로 본다.
- PATCH 실패 시 직전 값으로 롤백한다(값 변경 시 API 호출 + LoadingDialog 표시 구간에서 사용자 대기가 가능한 UX를 전제로 함).
- 연타 + 네트워크 지연 상황에서 LoadingDialog 과도 점멸을 방지하기 위해 표시 조건을 실제 API 요청 구간으로 제한한다(반영).
## 검증 계획
- 정적 확인: 응답/요청 DTO 필드, 메뉴 노출 조건식, debounce 상태 변수 흐름 점검
- 자동 검증: 단위 테스트 및 디버그 빌드 성공 확인
- 수동 검증: 한국/비한국 노출 케이스, 토글 연타 케이스, 마지막 값 전송 케이스
## 검증 기록
- 기록 템플릿(후속 누적):
- YYYY-MM-DD
- 무엇/왜/어떻게:
- 실행 명령/도구:
- `명령 또는 사용 도구`
- 결과:
- 2026-03-26
- 무엇/왜/어떻게: `/member/info` 응답 확장, 설정 메뉴 노출 확장, `/member/content-preference` PATCH, Non-Rx debounce 제약을 반영한 구현 계획 문서를 작성했다.
- 실행 명령/도구:
- `read(docs/*, UserApi.kt, GetMemberInfoResponse.kt, SettingsActivity.kt, ContentSettingsActivity.kt, ContentSettingsViewModel.kt, SharedPreferenceManager.kt, AppDI.kt)`
- `grep("/member/info|content-preference|isAuth|countryCode|ContentType")`
- `apply_patch(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md 생성)`
- 결과:
- 요구사항과 추가 제약("debounce는 RxJava 미사용")이 반영된 체크리스트 중심 계획 문서를 생성했다.
- 2026-03-26
- 무엇/왜/어떻게: 사용자 피드백에 따라 리스크/확인사항을 확정 대응안으로 갱신하고, `/member/info` 수신 필드 3종의 로컬 저장소 동기화 요구를 문서에 명시했다.
- 실행 명령/도구:
- `read(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md)`
- `apply_patch(docs/20260326_멤버정보응답확장및콘텐츠보기설정동기화.md 수정)`
- 결과:
- `contentType 동일 타입 사용`, `멤버 정보 호출 타이밍 이슈 없음`, `PATCH 실패 롤백`, `LoadingDialog API 구간 제한`이 문서에 반영되었다.
- `/member/info` 확장 필드(`countryCode`, `isAdultContentVisible`, `contentType`)의 로컬 저장소 동기화 요구가 AC/체크리스트에 반영되었다.
- 2026-03-26
- 무엇/왜/어떻게: 계획 문서 기준으로 `/member/info` 응답 확장 필드 로컬 동기화, 설정 메뉴 노출 조건 확장, `/member/content-preference` PATCH 연동, ContentSettings의 Non-Rx debounce + in-flight/pending 마지막 값 전송 보장, LoadingDialog 연동을 구현했다.
- 실행 명령/도구:
- `task(explore x2)`
- `grep/read(app/src/main/java/**)`
- `apply_patch(소스 12개 파일 + DTO 2개 파일)`
- `lsp_diagnostics(수정된 .kt 파일 일괄 시도)`
- `./gradlew :app:testDebugUnitTest`
- `./gradlew :app:assembleDebug`
- 결과:
- `GetMemberInfoResponse``countryCode/isAdultContentVisible/contentType`가 추가되고 `MainViewModel.getMemberInfo()`에서 `SharedPreferenceManager`로 동기화된다.
- `SettingsActivity``콘텐츠 보기 설정` 노출 조건이 `isAuth || countryCode != "KR"`로 확장되었다.
- `UserApi/UserRepository``/member/content-preference` PATCH와 요청/응답 DTO가 추가되었다.
- `ContentSettingsViewModel``Handler + Runnable` 기반 debounce와 in-flight/pending 제어를 적용해 연타 시 마지막 상태만 서버로 전송되며, 요청 중에만 `isLoading`이 true가 된다.
- `ContentSettingsActivity``isLoading`을 관찰해 `LoadingDialog`를 표시/해제하고 오류 토스트를 노출한다.
- `lsp_diagnostics`는 현재 환경에 Kotlin LSP가 없어 실행 불가 메시지를 확인했다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다.
- 2026-03-26
- 무엇/왜/어떻게: 정적 검사 대체 검증과 실제 단말 스모크 실행으로 구현 안정성을 추가 확인했다.
- 실행 명령/도구:
- `./gradlew :app:ktlintCheck`
- `adb devices`
- `adb install -r app/build/outputs/apk/debug/app-debug.apk`
- `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`
- `adb shell dumpsys activity | grep -E "ResumedActivity|mFocusedApp|topResumedActivity|mCurrentFocus"`
- `adb logcat -d -s AndroidRuntime`
- `adb shell pidof kr.co.vividnext.sodalive.debug`
- 결과:
- `ktlintCheck`는 기존 코드베이스(주로 `LiveRoomActivity` 등) 기등록 스타일 위반으로 실패했다.
- 이번 변경 파일 기준으로는 `ContentSettingsActivity`, `GetMemberInfoResponse` 지적 항목을 정리 후 재빌드하여 `:app:testDebugUnitTest`, `:app:assembleDebug` 재성공을 확인했다.
- 디버그 APK 설치 및 런처 실행(monkey) 후 `SplashActivity`가 resumed 상태이며 앱 프로세스(pid) 실행 중임을 확인했다.
- 2026-03-27
- 무엇/왜/어떻게: 추가 요청에 따라 `MyPageFragment`의 본인인증/인증완료 아이템을 한국 접속국가에서만 노출하도록 분기했다.
- 실행 명령/도구:
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/mypage/MyPageFragment.kt)`
- `./gradlew :app:testDebugUnitTest`
- `./gradlew :app:assembleDebug`
- 결과:
- `countryCode.ifBlank { "KR" } == "KR"` 조건에서만 `btnIdentityVerification`이 표시되며, 비KR에서는 숨김 처리된다.
- `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다.
- 2026-03-27
- 무엇/왜/어떻게: `/member/content-preference` 요청에 변경된 값만 전송하기 위해 request 필드를 optional로 전환하고, `confirmedState` 대비 변경 필드만 body에 담아 PATCH 호출하도록 조정했다.
- 실행 명령/도구:
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt)`
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/settings/UpdateContentPreferenceRequest.kt)`
- `lsp_diagnostics(app/src/main/java/kr/co/vividnext/sodalive/settings/ContentSettingsViewModel.kt)`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과:
- `UpdateContentPreferenceRequest``isAdultContentVisible`, `contentType`가 nullable optional로 변경되었다.
- `ContentSettingsViewModel.syncContentPreference()`에서 `confirmedState`와 비교해 변경된 필드만 request에 포함하며, 변경 필드가 없으면 API 호출을 생략한다.
- `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다.
- 2026-03-27
- 무엇/왜/어떻게: 캐릭터 터치 후 상세 진입 시 사용되는 `ensureLoginAndAuth` 인증 체크를 접속국가 기준으로 분기했다. KR은 기존 본인인증 다이얼로그 흐름을 유지하고, 비KR은 `isAdultContentVisible`이 꺼져 있으면 `ContentSettingsActivity`로 이동해 민감한 콘텐츠 스위치 안내 흐름을 사용하도록 맞췄다.
- 실행 명령/도구:
- `task(explore x2: 캐릭터 상세 진입 경로/인증 게이트 탐색)`
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt)`
- `apply_patch(app/src/main/java/kr/co/vividnext/sodalive/chat/character/CharacterTabFragment.kt)`
- `lsp_diagnostics(HomeFragment.kt, CharacterTabFragment.kt)`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- `adb devices`
- `adb install -r app/build/outputs/apk/debug/app-debug.apk`
- `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`
- `adb shell uiautomator dump /sdcard/window_dump.xml`
- `adb pull /sdcard/window_dump.xml /tmp/sodalive_window_dump.xml`
- 결과:
- `HomeFragment``CharacterTabFragment`의 캐릭터 상세 진입 전 인증 체크가 `countryCode.ifBlank { "KR" } == "KR"` 기준으로 분기된다.
- KR에서 미인증이면 기존 인증 다이얼로그/인증 플로우를 유지한다.
- 비KR에서 `isAdultContentVisible == false`이면 `ContentSettingsActivity`로 이동하고 `EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE`를 전달한다.
- `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 모두 성공했다.
- 수동 검증 시도 중 ADB 장치 연결이 해제되어 캐릭터 탭 실제 터치 시나리오를 끝까지 완료하지 못했다.
- 2026-03-27
- 무엇/왜/어떻게: 한국 접속 + 본인인증 완료 상태에서도 `isAdultContentVisible == false`이면 19금 라이브 상세와 채팅 캐릭터 상세를 비KR OFF와 동일하게 처리하도록 게이트 조건을 조정했다.
- 실행 명령/도구:
- `task(explore: live/character gate 위치 점검)`
- `grep("ensureLoginAndAdultAuth|ensureLoginAndAuth|onCharacterClick")`
- `apply_patch(HomeFragment.kt, LiveFragment.kt, LiveNowAllActivity.kt, CharacterTabFragment.kt)`
- `lsp_diagnostics(수정된 .kt 파일 4개)`
- `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- `adb devices`
- `adb shell am force-stop kr.co.vividnext.sodalive.debug`
- `adb shell monkey -p kr.co.vividnext.sodalive.debug -c android.intent.category.LAUNCHER 1`
- `adb shell uiautomator dump /sdcard/window_dump.xml`
- 결과:
- KR에서 `isAuth=true`라도 `isAdultContentVisible=false`이면 19금 라이브 상세/캐릭터 상세 진입 시 `ContentSettingsActivity` 안내 흐름으로 분기된다.
- 기존 KR 미인증 케이스의 본인인증 다이얼로그 흐름은 유지된다.
- 자동 검증: `:app:testDebugUnitTest`, `:app:assembleDebug` 성공.
- `lsp_diagnostics`는 현재 환경에서 Kotlin LSP 미구성으로 실행 불가 메시지를 확인했다.
- 수동 QA는 디바이스 연결이 반복 해제되어 조건 기반 실제 탭 시나리오를 끝까지 수행하지 못했다.