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.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,7 +1374,9 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = requireActivity(), activity = requireActivity(),
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -1378,6 +1391,16 @@ 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()
} }

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,7 +863,9 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = requireActivity(), activity = requireActivity(),
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -877,6 +880,16 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
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

@@ -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,7 +120,9 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
return return
} }
if (isAdult && !SharedPreferenceManager.isAuth) { if (isAdult) {
val isKoreanCountry = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanCountry && !SharedPreferenceManager.isAuth) {
SodaDialog( SodaDialog(
activity = this, activity = this,
layoutInflater = layoutInflater, layoutInflater = layoutInflater,
@@ -134,6 +137,16 @@ class LiveNowAllActivity : BaseActivity<ActivityLiveNowAllBinding>(
return return
} }
if (!SharedPreferenceManager.isAdultContentVisible) {
startActivity(
Intent(applicationContext, ContentSettingsActivity::class.java).apply {
putExtra(Constants.EXTRA_SHOW_SENSITIVE_CONTENT_GUIDE, true)
}
)
return
}
}
onAuthed() 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.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.json.JSONObject import org.json.JSONObject
@@ -1053,9 +1054,26 @@ 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
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( SodaDialog(
this@LiveRoomActivity, this@LiveRoomActivity,
layoutInflater, layoutInflater,
@@ -1066,6 +1084,7 @@ class LiveRoomActivity : BaseActivity<ActivityLiveRoomBinding>(ActivityLiveRoomB
).show(screenWidth) ).show(screenWidth)
} }
} }
}
donationMessageViewModel.isLoading.observe(this) { donationMessageViewModel.isLoading.observe(this) {
if (it) { if (it) {

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

@@ -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) {
val isKoreanUser = SharedPreferenceManager.countryCode.ifBlank { "KR" } == "KR"
if (isKoreanUser) {
binding.btnIdentityVerification.root.visibility = View.VISIBLE
if (it.isAuth) { if (it.isAuth) {
FunctionButtonHelper.setupFunctionButton( FunctionButtonHelper.setupFunctionButton(
buttonView = binding.btnIdentityVerification.root, buttonView = binding.btnIdentityVerification.root,
iconRes = R.drawable.ic_my_auth, iconRes = R.drawable.ic_my_auth,
title = getString(R.string.screen_my_identity_verified) 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(
this,
object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() { override fun handleOnBackPressed() {
handleFinish() 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 {
val isAdultContentVisible = viewModel.isAdultContentVisible.value == true
if (isAdultContentVisible) {
viewModel.toggleAdultContentVisible() 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
if (adultContentVisible) { } else {
SharedPreferenceManager.contentPreference = ContentType.ALL.ordinal currentState.contentType
} }
)
applyLocalState(nextState)
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

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