refactor(preferences): DataStore 설정 저장 안정성을 높인다

This commit is contained in:
2026-03-11 12:32:34 +09:00
parent 8e1dabbb80
commit 418b734c3f
16 changed files with 847 additions and 285 deletions

View File

@@ -0,0 +1,46 @@
package kr.co.vividnext.sodalive.common
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStoreFile
object AppPreferencesDataStoreProvider {
private const val DATASTORE_FILE_NAME = "sodalive_default_preferences"
private const val DEFAULT_SHARED_PREFERENCES_SUFFIX = "_preferences"
@Volatile
private var dataStore: DataStore<Preferences>? = null
fun get(context: Context): DataStore<Preferences> {
val existing = dataStore
if (existing != null) {
return existing
}
return synchronized(this) {
dataStore ?: createDataStore(context.applicationContext).also { dataStore = it }
}
}
private fun createDataStore(context: Context): DataStore<Preferences> {
val legacyPreferencesName = "${context.packageName}$DEFAULT_SHARED_PREFERENCES_SUFFIX"
return PreferenceDataStoreFactory.create(
migrations = listOf(
// 기존 기본 SharedPreferences 값은 DataStore 첫 접근 시 자동 이관된다.
SharedPreferencesMigration(context, legacyPreferencesName)
),
produceFile = { context.preferencesDataStoreFile(DATASTORE_FILE_NAME) }
)
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun resetForTest() {
synchronized(this) {
dataStore = null
}
}
}

View File

@@ -1,167 +1,270 @@
package kr.co.vividnext.sodalive.common
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kr.co.vividnext.sodalive.settings.notification.MemberRole
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object SharedPreferenceManager {
private lateinit var sharedPreferences: SharedPreferences
private const val PREF_APP_LANGUAGE_CODE = "pref_app_language_code"
private lateinit var dataStore: DataStore<Preferences>
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val preferenceState = MutableStateFlow<Map<String, Any?>>(emptyMap())
private val initLock = Any()
private var observerJob: Job? = null
@Volatile
private var initialized = false
val roleFlow: Flow<String> =
preferenceState.map { state ->
state[Constants.PREF_USER_ROLE] as? String ?: MemberRole.USER.name
}.distinctUntilChanged()
val isPlayerServiceRunningFlow: Flow<Boolean> =
preferenceState.map { state ->
state[Constants.PREF_IS_PLAYER_SERVICE_RUNNING] as? Boolean ?: false
}.distinctUntilChanged()
fun init(context: Context) {
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
}
if (initialized) {
return
}
fun registerOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener
) {
sharedPreferences.registerOnSharedPreferenceChangeListener(listener)
}
synchronized(initLock) {
if (initialized) {
return
}
fun unregisterOnSharedPreferenceChangeListener(
listener: SharedPreferences.OnSharedPreferenceChangeListener
) {
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener)
}
dataStore = AppPreferencesDataStoreProvider.get(context.applicationContext)
val initialPreferences = runBlocking {
dataStore.data.first()
}
updateState(initialPreferences)
initialized = true
fun clear() {
sharedPreferences.edit { editor ->
sharedPreferences.all.keys
.filterNot { it == Constants.PREF_PUSH_TOKEN }
.forEach { editor.remove(it) }
observerJob?.cancel()
observerJob = appScope.launch {
dataStore.data.collect { preferences ->
updateState(preferences)
}
}
}
}
private inline fun SharedPreferences.edit(operation: (SharedPreferences.Editor) -> Unit) {
val editor = this.edit()
operation(editor)
editor.apply()
private fun updateState(preferences: Preferences) {
preferenceState.value = preferences.asMap().entries.associate { (key, value) ->
key.name to value
}
}
private operator fun SharedPreferences.set(key: String, value: Any?) {
when (value) {
is String? -> edit { it.putString(key, value) }
is Int -> edit { it.putInt(key, value) }
is Boolean -> edit { it.putBoolean(key, value) }
is Float -> edit { it.putFloat(key, value) }
is Long -> edit { it.putLong(key, value) }
else -> throw UnsupportedOperationException("Error")
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun resetForTest() {
synchronized(initLock) {
observerJob?.cancel()
observerJob = null
preferenceState.value = emptyMap()
initialized = false
}
}
fun clear() {
ensureInitialized()
preferenceState.update { state ->
val pushToken = state[Constants.PREF_PUSH_TOKEN]
if (pushToken != null) {
mapOf(Constants.PREF_PUSH_TOKEN to pushToken)
} else {
emptyMap()
}
}
appScope.launch {
dataStore.edit { preferences ->
val keysToRemove = preferences.asMap().keys.filter { it.name != Constants.PREF_PUSH_TOKEN }
keysToRemove.forEach { preferenceKey ->
preferences.remove(preferenceKey)
}
}
}
}
private fun ensureInitialized() {
check(initialized) { "SharedPreferenceManager is not initialized." }
}
private fun setPreference(key: String, value: Any?) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
if (value == null) {
mutable.remove(key)
} else {
mutable[key] = value
}
mutable
}
appScope.launch {
dataStore.edit { preferences ->
when (value) {
null -> removeByName(preferences, key)
is String -> preferences[stringPreferencesKey(key)] = value
is Int -> preferences[intPreferencesKey(key)] = value
is Boolean -> preferences[booleanPreferencesKey(key)] = value
is Float -> preferences[floatPreferencesKey(key)] = value
is Long -> preferences[longPreferencesKey(key)] = value
else -> throw UnsupportedOperationException("Error")
}
}
}
}
@Suppress("UNCHECKED_CAST")
private operator fun <T> SharedPreferences.get(key: String, defaultValue: T? = null): T {
private fun <T> getPreference(key: String, defaultValue: T? = null): T {
ensureInitialized()
val value = preferenceState.value[key]
return when (defaultValue) {
is String, null -> getString(key, defaultValue as? String) as T
is Int -> getInt(key, defaultValue as? Int ?: -1) as T
is Boolean -> getBoolean(key, defaultValue as? Boolean ?: false) as T
is Float -> getFloat(key, defaultValue as? Float ?: -1f) as T
is Long -> getLong(key, defaultValue as? Long ?: -1) as T
is String, null -> (value as? String ?: defaultValue as? String) as T
is Int -> (value as? Int ?: defaultValue) as T
is Boolean -> (value as? Boolean ?: defaultValue) as T
is Float -> (value as? Float ?: defaultValue) as T
is Long -> (value as? Long ?: defaultValue) as T
else -> throw UnsupportedOperationException("Error")
}
}
private fun removeByName(preferences: MutablePreferences, key: String) {
val targetKey = preferences.asMap().keys.firstOrNull { it.name == key } ?: return
preferences.remove(targetKey)
}
var token: String
get() = sharedPreferences[Constants.PREF_TOKEN, ""]
get() = getPreference(Constants.PREF_TOKEN, "")
set(value) {
sharedPreferences[Constants.PREF_TOKEN] = value
setPreference(Constants.PREF_TOKEN, value)
}
var userId: Long
get() = sharedPreferences[Constants.PREF_USER_ID, 0]
get() = getPreference(Constants.PREF_USER_ID, 0L)
set(value) {
sharedPreferences[Constants.PREF_USER_ID] = value
setPreference(Constants.PREF_USER_ID, value)
}
var nickname: String
get() = sharedPreferences[Constants.PREF_NICKNAME, ""]
get() = getPreference(Constants.PREF_NICKNAME, "")
set(value) {
sharedPreferences[Constants.PREF_NICKNAME] = value
setPreference(Constants.PREF_NICKNAME, value)
}
var email: String
get() = sharedPreferences[Constants.PREF_EMAIL, ""]
get() = getPreference(Constants.PREF_EMAIL, "")
set(value) {
sharedPreferences[Constants.PREF_EMAIL] = value
setPreference(Constants.PREF_EMAIL, value)
}
var profileImage: String
get() = sharedPreferences[Constants.PREF_PROFILE_IMAGE, ""]
get() = getPreference(Constants.PREF_PROFILE_IMAGE, "")
set(value) {
sharedPreferences[Constants.PREF_PROFILE_IMAGE] = value
setPreference(Constants.PREF_PROFILE_IMAGE, value)
}
var can: Int
get() = sharedPreferences[Constants.PREF_CAN, 0]
get() = getPreference(Constants.PREF_CAN, 0)
set(value) {
sharedPreferences[Constants.PREF_CAN] = value
setPreference(Constants.PREF_CAN, value)
}
var point: Int
get() = sharedPreferences[Constants.PREF_POINT, 0]
get() = getPreference(Constants.PREF_POINT, 0)
set(value) {
sharedPreferences[Constants.PREF_POINT] = value
setPreference(Constants.PREF_POINT, value)
}
var role: String
get() = sharedPreferences[Constants.PREF_USER_ROLE, MemberRole.USER.name]
get() = getPreference(Constants.PREF_USER_ROLE, MemberRole.USER.name)
set(value) {
sharedPreferences[Constants.PREF_USER_ROLE] = value
setPreference(Constants.PREF_USER_ROLE, value)
}
var isAuth: Boolean
get() = sharedPreferences[Constants.PREF_IS_ADULT, false]
get() = getPreference(Constants.PREF_IS_ADULT, false)
set(value) {
sharedPreferences[Constants.PREF_IS_ADULT] = value
setPreference(Constants.PREF_IS_ADULT, value)
}
var isAuditionNotification: Boolean
get() = sharedPreferences[Constants.PREF_IS_AUDITION_NOTIFICATION, false]
get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false)
set(value) {
sharedPreferences[Constants.PREF_IS_AUDITION_NOTIFICATION] = value
setPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, value)
}
var isAdultContentVisible: Boolean
get() = sharedPreferences[Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true]
get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true)
set(value) {
sharedPreferences[Constants.PREF_IS_ADULT_CONTENT_VISIBLE] = value
setPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, value)
}
var contentPreference: Int
get() = sharedPreferences[Constants.PREF_CONTENT_PREFERENCE, 0]
get() = getPreference(Constants.PREF_CONTENT_PREFERENCE, 0)
set(value) {
sharedPreferences[Constants.PREF_CONTENT_PREFERENCE] = value
setPreference(Constants.PREF_CONTENT_PREFERENCE, value)
}
var pushToken: String
get() = sharedPreferences[Constants.PREF_PUSH_TOKEN, ""]
get() = getPreference(Constants.PREF_PUSH_TOKEN, "")
set(value) {
sharedPreferences[Constants.PREF_PUSH_TOKEN] = value
setPreference(Constants.PREF_PUSH_TOKEN, value)
}
var isContentPlayLoop: Boolean
get() = sharedPreferences[Constants.PREF_IS_CONTENT_PLAY_LOOP, false]
get() = getPreference(Constants.PREF_IS_CONTENT_PLAY_LOOP, false)
set(value) {
sharedPreferences[Constants.PREF_IS_CONTENT_PLAY_LOOP] = value
setPreference(Constants.PREF_IS_CONTENT_PLAY_LOOP, value)
}
var notShowingEventPopupId: Long
get() = sharedPreferences[Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID, 0]
get() = getPreference(Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID, 0L)
set(value) {
sharedPreferences[Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID] = value
setPreference(Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID, value)
}
var isViewedOnboardingTutorial: Boolean
get() = sharedPreferences[Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL, false]
get() = getPreference(Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL, false)
set(value) {
sharedPreferences[Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL] = value
setPreference(Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL, value)
}
var noChatRoomList: List<Long>
get() {
val list = sharedPreferences[Constants.PREF_NO_CHAT_ROOM, ""]
val list = getPreference(Constants.PREF_NO_CHAT_ROOM, "")
val gson = Gson()
val listType = object : TypeToken<List<Long>>() {}.type
val myList = gson.fromJson<List<Long>>(list, listType)
@@ -170,54 +273,60 @@ object SharedPreferenceManager {
set(value) {
val gson = Gson()
val listJson = gson.toJson(value)
sharedPreferences[Constants.PREF_NO_CHAT_ROOM] = listJson
setPreference(Constants.PREF_NO_CHAT_ROOM, listJson)
}
var isPlayerServiceRunning: Boolean
get() = sharedPreferences[Constants.PREF_IS_PLAYER_SERVICE_RUNNING, false]
get() = getPreference(Constants.PREF_IS_PLAYER_SERVICE_RUNNING, false)
set(value) {
sharedPreferences[Constants.PREF_IS_PLAYER_SERVICE_RUNNING] = value
setPreference(Constants.PREF_IS_PLAYER_SERVICE_RUNNING, value)
}
var marketingPid: String
get() = sharedPreferences[Constants.PREF_MARKETING_PID, ""]
get() = getPreference(Constants.PREF_MARKETING_PID, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_PID] = value
setPreference(Constants.PREF_MARKETING_PID, value)
}
var marketingUtmSource: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_SOURCE, ""]
get() = getPreference(Constants.PREF_MARKETING_UTM_SOURCE, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_SOURCE] = value
setPreference(Constants.PREF_MARKETING_UTM_SOURCE, value)
}
var marketingUtmMedium: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_MEDIUM, ""]
get() = getPreference(Constants.PREF_MARKETING_UTM_MEDIUM, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_MEDIUM] = value
setPreference(Constants.PREF_MARKETING_UTM_MEDIUM, value)
}
var marketingUtmCampaign: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_CAMPAIGN, ""]
get() = getPreference(Constants.PREF_MARKETING_UTM_CAMPAIGN, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_CAMPAIGN] = value
setPreference(Constants.PREF_MARKETING_UTM_CAMPAIGN, value)
}
var marketingLinkValue: String
get() = sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE, ""]
get() = getPreference(Constants.PREF_MARKETING_LINK_VALUE, "")
set(value) {
sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE] = value
setPreference(Constants.PREF_MARKETING_LINK_VALUE, value)
}
var marketingLinkValueId: Long
get() = sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE_ID, 0L]
get() = getPreference(Constants.PREF_MARKETING_LINK_VALUE_ID, 0L)
set(value) {
sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE_ID] = value
setPreference(Constants.PREF_MARKETING_LINK_VALUE_ID, value)
}
var alreadyTrackingAppLaunch: Boolean
get() = sharedPreferences[Constants.PREF_ALREADY_TRACKING_APP_LAUNCH, true]
get() = getPreference(Constants.PREF_ALREADY_TRACKING_APP_LAUNCH, true)
set(value) {
sharedPreferences[Constants.PREF_ALREADY_TRACKING_APP_LAUNCH] = value
setPreference(Constants.PREF_ALREADY_TRACKING_APP_LAUNCH, value)
}
var appLanguageCode: String
get() = getPreference(PREF_APP_LANGUAGE_CODE, "")
set(value) {
setPreference(PREF_APP_LANGUAGE_CODE, value)
}
}