fix(content): 국가별 성인 콘텐츠 접근 동기화를 정리한다

This commit is contained in:
2026-03-27 17:33:52 +09:00
parent 6aa7b9e98c
commit 0fcd929c6f
23 changed files with 757 additions and 93 deletions

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.NewCharactersAllAdapter
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.SharedPreferenceManager
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.auth.Auth
import kr.co.vividnext.sodalive.mypage.auth.AuthVerifyRequest
import kr.co.vividnext.sodalive.settings.ContentSettingsActivity
import kr.co.vividnext.sodalive.splash.SplashActivity
import org.koin.android.ext.android.inject
@@ -375,7 +377,8 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
return
}
if (!SharedPreferenceManager.isAuth) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
@@ -390,6 +393,15 @@ class CharacterTabFragment : BaseFragment<FragmentCharacterTabBinding>(
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
onAuthed()
}

View File

@@ -7,6 +7,7 @@ object Constants {
const val PREF_EMAIL = "pref_email"
const val PREF_USER_ID = "pref_user_id"
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_USER_ROLE = "pref_user_role"
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_PLAYLIST_SEGMENT_LOOP_IMAGE = "extra_playlist_segment_loop_image"
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 ACTION_AUDIO_CONTENT_RECEIVER = "soda_live_action_content_receiver"

View File

@@ -220,6 +220,12 @@ object SharedPreferenceManager {
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
get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false)
set(value) {
@@ -227,7 +233,7 @@ object SharedPreferenceManager {
}
var isAdultContentVisible: Boolean
get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true)
get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, false)
set(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 { NotificationSettingsViewModel(get()) }
viewModel { NotificationReceiveSettingsViewModel(get(), get()) }
viewModel { ContentSettingsViewModel() }
viewModel { ContentSettingsViewModel(get()) }
viewModel { SettingsViewModel(get(), get()) }
viewModel { SeriesDetailViewModel(get(), 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.can.charge.CanChargeActivity
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.language.LanguageManager
import kr.co.vividnext.sodalive.settings.language.LocaleHelper
@@ -1339,7 +1340,8 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return
}
if (!SharedPreferenceManager.isAuth) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
@@ -1354,6 +1356,15 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
onAuthed()
}
@@ -1363,7 +1374,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return
}
if (isAdult && !SharedPreferenceManager.isAuth) {
if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
@@ -1378,6 +1391,16 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
}
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.can.charge.CanChargeActivity
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.LocaleHelper
import kr.co.vividnext.sodalive.settings.notification.MemberRole
@@ -862,7 +863,9 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
return
}
if (isAdult && !SharedPreferenceManager.isAuth) {
if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = requireActivity(),
layoutInflater = layoutInflater,
@@ -877,6 +880,16 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(requireContext(), ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
}
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.AuthVerifyRequest
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.LocaleHelper
import kr.co.vividnext.sodalive.splash.SplashActivity
@@ -119,7 +120,9 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
return
}
if (isAdult && !SharedPreferenceManager.isAuth) {
if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog(
activity = this,
layoutInflater = layoutInflater,
@@ -134,6 +137,16 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
return
}
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(applicationContext, ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
}
onAuthed()
}

View File

@@ -124,6 +124,7 @@ import kr.co.vividnext.sodalive.main.MainActivity
import kr.co.vividnext.sodalive.report.ProfileReportDialog
import kr.co.vividnext.sodalive.report.ReportType
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.notification.MemberRole
import org.json.JSONObject
@@ -1053,9 +1054,26 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
}
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)
binding.rvChat.visibility = View.INVISIBLE
if (shouldBlockBySensitiveContent) {
showToast(getString(R.string.screen_content_settings_sensitive_content_guide))
startActivity(
Intent(applicationContext, ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
finish()
} else {
SodaDialog(
this@LiveRoomActivity,
layoutInflater,
@@ -1066,6 +1084,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
).show(screenWidth)
}
}
}
donationMessageViewModel.isLoading.observe(this) {
if (it) {

View File

@@ -250,7 +250,14 @@ class LiveRoomViewModel(
getTotalDonationCan(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
}

View File

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

View File

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

View File

@@ -3,9 +3,13 @@ package kr.co.vividnext.sodalive.settings
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import kr.co.vividnext.sodalive.R
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.databinding.ActivityContentSettingsBinding
import kr.co.vividnext.sodalive.splash.SplashActivity
@@ -16,37 +20,74 @@ class ContentSettingsActivity : BaseActivity<ActivityContentSettingsBinding>(
) {
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?) {
super.onCreate(savedInstanceState)
bindData()
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
onBackPressedDispatcher.addCallback(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
handleFinish()
}
})
}
)
}
override fun setupView() {
loadingDialog = LoadingDialog(this, layoutInflater)
binding.toolbar.tvBack.text = getString(R.string.screen_content_settings_title)
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
// 19금 콘텐츠 보기 체크
if (SharedPreferenceManager.isAdultContentVisible) {
binding.llAdultContentPreference.visibility = View.VISIBLE
} else {
binding.llAdultContentPreference.visibility = View.GONE
}
// 19금 콘텐츠 보기 스위치 액션
binding.ivAdultContentVisible.setOnClickListener {
val isAdultContentVisible = viewModel.isAdultContentVisible.value == true
if (isAdultContentVisible) {
viewModel.toggleAdultContentVisible()
} else {
sensitiveContentConfirmDialog.show(screenWidth)
}
}
binding.tvContentAll.setOnClickListener {
@@ -88,6 +129,21 @@ class ContentSettingsActivity : BaseActivity<ActivityContentSettingsBinding>(
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() {

View File

@@ -1,39 +1,201 @@
package kr.co.vividnext.sodalive.settings
import android.os.Handler
import android.os.Looper
import androidx.lifecycle.LiveData
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.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.ToastMessage
import kr.co.vividnext.sodalive.user.UserRepository
class ContentSettingsViewModel : BaseViewModel() {
private var _isAdultContentVisible = MutableLiveData(
SharedPreferenceManager.isAdultContentVisible
class ContentSettingsViewModel(
private val userRepository: UserRepository
) : 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>
get() = _isAdultContentVisible
private var _adultContentPreference = MutableLiveData(
ContentType.values()[SharedPreferenceManager.contentPreference]
)
private val _adultContentPreference = MutableLiveData(initialState.contentType)
val adultContentPreference: LiveData<ContentType>
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
private set
fun toggleAdultContentVisible() {
val adultContentVisible = SharedPreferenceManager.isAdultContentVisible
_isAdultContentVisible.value = !adultContentVisible
SharedPreferenceManager.isAdultContentVisible = !adultContentVisible
isChangedAdultContentVisible = true
if (adultContentVisible) {
SharedPreferenceManager.contentPreference = ContentType.ALL.ordinal
val currentState = getCurrentPreferenceState()
val nextState = PreferenceState(
isAdultContentVisible = !currentState.isAdultContentVisible,
contentType = if (currentState.isAdultContentVisible) {
ContentType.ALL
} else {
currentState.contentType
}
)
applyLocalState(nextState)
queueLatestStateForSync()
}
fun setAdultContentPreference(adultContentPreference: ContentType) {
_adultContentPreference.value = adultContentPreference
SharedPreferenceManager.contentPreference = adultContentPreference.ordinal
isChangedAdultContentVisible = true
val currentState = getCurrentPreferenceState()
if (currentState.contentType == adultContentPreference) {
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.rlContentSettings.visibility = View.VISIBLE
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 com.google.gson.annotations.SerializedName
import kr.co.vividnext.sodalive.settings.ContentType
@Keep
data class GetMemberInfoResponse(
@@ -18,7 +19,13 @@ data class GetMemberInfoResponse(
@SerializedName("followingChannelUploadContentNotice")
val followingChannelUploadContentNotice: Boolean?,
@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 {
@@ -26,5 +33,5 @@ enum class MemberRole {
USER,
@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.ProfileUpdateRequest
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.UpdateNotificationSettingRequest
import kr.co.vividnext.sodalive.settings.signout.SignOutRequest
@@ -26,6 +28,7 @@ import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
@@ -49,6 +52,12 @@ interface UserApi {
@Header("Authorization") authHeader: String
): Single<ApiResponse<GetMemberInfoResponse>>
@PATCH("/member/content-preference")
fun updateContentPreference(
@Body request: UpdateContentPreferenceRequest,
@Header("Authorization") authHeader: String
): Single<ApiResponse<UpdateContentPreferenceResponse>>
@GET("/push/notification/categories")
fun getPushNotificationCategories(
@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.profile.ProfileResponse
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.signout.SignOutRequest
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 updateContentPreference(
request: UpdateContentPreferenceRequest,
token: String
): Single<ApiResponse<UpdateContentPreferenceResponse>> {
return userApi.updateContentPreference(request = request, authHeader = token)
}
fun getPushNotificationCategories(token: String): Single<ApiResponse<GetPushNotificationCategoryResponse>> {
return userApi.getPushNotificationCategories(authHeader = token)
}

View File

@@ -994,6 +994,9 @@
<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_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_male">Male-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="screen_content_settings_title">コンテンツ表示設定</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_male">男性向け</string>
<string name="screen_content_settings_female">女性向け</string>

View File

@@ -993,6 +993,9 @@
<string name="dialog_logout_all_message">모든 기기에서 로그아웃 하시겠어요?</string>
<string name="screen_content_settings_title">콘텐츠 보기 설정</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_male">남성향</string>
<string name="screen_content_settings_female">여성향</string>

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는 디바이스 연결이 반복 해제되어 조건 기반 실제 탭 시나리오를 끝까지 수행하지 못했다.