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

@@ -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) {
override fun handleOnBackPressed() {
handleFinish()
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 {
viewModel.toggleAdultContentVisible()
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
val currentState = getCurrentPreferenceState()
val nextState = PreferenceState(
isAdultContentVisible = !currentState.isAdultContentVisible,
contentType = if (currentState.isAdultContentVisible) {
ContentType.ALL
} else {
currentState.contentType
}
)
if (adultContentVisible) {
SharedPreferenceManager.contentPreference = ContentType.ALL.ordinal
}
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
}