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

@@ -65,6 +65,7 @@ android {
targetSdk 35 targetSdk 35
versionCode 224 versionCode 224
versionName "1.51.1" versionName "1.51.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
buildTypes { buildTypes {
@@ -165,6 +166,7 @@ dependencies {
exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel' exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel'
exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx' exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx'
} }
implementation "androidx.datastore:datastore-preferences:1.2.0"
// Gson // Gson
implementation "com.google.code.gson:gson:2.13.2" implementation "com.google.code.gson:gson:2.13.2"
@@ -253,6 +255,10 @@ dependencies {
testImplementation 'org.mockito:mockito-inline:5.2.0' testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0' testImplementation 'org.mockito.kotlin:mockito-kotlin:6.1.0'
testImplementation 'io.mockk:mockk:1.14.6' testImplementation 'io.mockk:mockk:1.14.6'
androidTestImplementation 'androidx.test:core-ktx:1.6.1'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
androidTestImplementation 'androidx.test:runner:1.6.2'
} }

View File

@@ -0,0 +1,88 @@
package kr.co.vividnext.sodalive.runtime
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomPreferenceManager
import kr.co.vividnext.sodalive.common.AppPreferencesDataStoreProvider
import kr.co.vividnext.sodalive.common.Constants
import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import kotlinx.coroutines.runBlocking
@RunWith(AndroidJUnit4::class)
class DataStoreRuntimeRegressionTest {
private val context: Context
get() = ApplicationProvider.getApplicationContext()
@Before
fun setUp() {
SharedPreferenceManager.init(context)
ChatRoomPreferenceManager.init(context)
}
@After
fun tearDown() {
SharedPreferenceManager.resetForTest()
ChatRoomPreferenceManager.resetForTest()
}
@Test
fun sharedPreferenceManager_readsPersistedSnapshotImmediatelyOnInit() {
runBlocking {
AppPreferencesDataStoreProvider.get(context).edit { preferences ->
preferences[stringPreferencesKey(PREF_APP_LANGUAGE_CODE)] = "en"
preferences[booleanPreferencesKey(Constants.PREF_IS_PLAYER_SERVICE_RUNNING)] = true
}
}
SharedPreferenceManager.resetForTest()
SharedPreferenceManager.init(context)
assertEquals("en", SharedPreferenceManager.appLanguageCode)
assertTrue(SharedPreferenceManager.isPlayerServiceRunning)
}
@Test
fun chatRoomPreferenceManager_readsPersistedSnapshotImmediatelyOnInit() {
val roomId = 13579L
val visibleKey = "chat_bg_visible_room_$roomId"
val imageIdKey = "chat_bg_image_id_room_$roomId"
runBlocking {
chatRoomDataStore().edit { preferences ->
preferences[booleanPreferencesKey(visibleKey)] = false
preferences[longPreferencesKey(imageIdKey)] = 777L
}
}
ChatRoomPreferenceManager.resetForTest()
ChatRoomPreferenceManager.init(context)
assertEquals(false, ChatRoomPreferenceManager.getBoolean(visibleKey, true))
assertEquals(777L, ChatRoomPreferenceManager.getLong(imageIdKey, -1L))
}
@Suppress("UNCHECKED_CAST")
private fun chatRoomDataStore(): DataStore<Preferences> {
val field = ChatRoomPreferenceManager::class.java.getDeclaredField("dataStore")
field.isAccessible = true
return field.get(ChatRoomPreferenceManager) as DataStore<Preferences>
}
companion object {
private const val PREF_APP_LANGUAGE_CODE = "pref_app_language_code"
}
}

View File

@@ -0,0 +1,69 @@
package kr.co.vividnext.sodalive.runtime
import androidx.media3.session.MediaController
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.SettableFuture
import kr.co.vividnext.sodalive.audio_content.playlist.detail.AudioContentPlaylistDetailActivity
import kr.co.vividnext.sodalive.main.MainActivity
import org.junit.Assert.assertSame
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MiniPlayerConnectionGuardTest {
@Test
fun mainActivity_skipsConnectingWhenFutureAlreadyExists() {
val activity = createOnMainThread { MainActivity() }
val sentinel = SettableFuture.create<MediaController>()
setPrivateField(activity, "mediaControllerFuture", sentinel)
invokePrivateNoArg(activity, "connectPlayerService")
@Suppress("UNCHECKED_CAST")
val after = getPrivateField(activity, "mediaControllerFuture") as? ListenableFuture<MediaController>
assertSame(sentinel, after)
}
@Test
fun playlistDetailActivity_skipsConnectingWhenFutureAlreadyExists() {
val activity = createOnMainThread { AudioContentPlaylistDetailActivity() }
val sentinel = SettableFuture.create<MediaController>()
setPrivateField(activity, "mediaControllerFuture", sentinel)
invokePrivateNoArg(activity, "connectPlayerService")
@Suppress("UNCHECKED_CAST")
val after = getPrivateField(activity, "mediaControllerFuture") as? ListenableFuture<MediaController>
assertSame(sentinel, after)
}
private fun setPrivateField(target: Any, fieldName: String, value: Any?) {
val field = target.javaClass.getDeclaredField(fieldName)
field.isAccessible = true
field.set(target, value)
}
private fun getPrivateField(target: Any, fieldName: String): Any? {
val field = target.javaClass.getDeclaredField(fieldName)
field.isAccessible = true
return field.get(target)
}
private fun invokePrivateNoArg(target: Any, methodName: String) {
val method = target.javaClass.getDeclaredMethod(methodName)
method.isAccessible = true
method.invoke(target)
}
private fun <T> createOnMainThread(factory: () -> T): T {
var instance: Any? = null
InstrumentationRegistry.getInstrumentation().runOnMainSync {
instance = factory()
}
@Suppress("UNCHECKED_CAST")
return instance as T
}
}

View File

@@ -16,6 +16,7 @@ import com.orhanobut.logger.AndroidLogAdapter
import com.orhanobut.logger.Logger import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.BuildConfig import kr.co.vividnext.sodalive.BuildConfig
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.chat.talk.room.ChatRoomPreferenceManager
import kr.co.vividnext.sodalive.common.ImageLoaderProvider import kr.co.vividnext.sodalive.common.ImageLoaderProvider
import kr.co.vividnext.sodalive.common.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder
@@ -40,6 +41,7 @@ class SodaLiveApp : Application(), DefaultLifecycleObserver {
SodaLiveApplicationHolder.init(this) SodaLiveApplicationHolder.init(this)
SharedPreferenceManager.init(applicationContext) SharedPreferenceManager.init(applicationContext)
ChatRoomPreferenceManager.init(applicationContext)
ImageLoaderProvider.init(applicationContext) ImageLoaderProvider.init(applicationContext)

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.audio_content.playlist.detail
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Rect import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@@ -13,6 +12,9 @@ import android.widget.ImageView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
@@ -23,6 +25,8 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import coil.load import coil.load
import coil.transform.RoundedCornersTransformation import coil.transform.RoundedCornersTransformation
import com.google.common.util.concurrent.ListenableFuture
import com.orhanobut.logger.Logger
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService import kr.co.vividnext.sodalive.audio_content.AudioContentPlayService
import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment import kr.co.vividnext.sodalive.audio_content.player.AudioContentPlayerFragment
@@ -36,6 +40,9 @@ import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import kr.co.vividnext.sodalive.databinding.ActivityAudioContentPlaylistDetailBinding import kr.co.vividnext.sodalive.databinding.ActivityAudioContentPlaylistDetailBinding
import kr.co.vividnext.sodalive.extensions.dpToPx import kr.co.vividnext.sodalive.extensions.dpToPx
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.random.Random import kotlin.random.Random
@UnstableApi @UnstableApi
@@ -60,25 +67,10 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
private val contentList = mutableListOf<AudioContentPlaylistContent>() private val contentList = mutableListOf<AudioContentPlaylistContent>()
private var mediaController: MediaController? = null private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
@SuppressLint("SetTextI18n") private var playerStateJob: Job? = null
private val preferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
// 특정 키에 대한 값이 변경될 때 UI 업데이트
if (key == Constants.PREF_IS_PLAYER_SERVICE_RUNNING) {
if (sharedPreferences.getBoolean(key, false)) {
handler.postDelayed(
{
initAndVisibleMiniPlayer()
},
1500
)
} else {
deInitMiniPlayer()
}
}
}
private fun initAndVisibleMiniPlayer() { private fun initAndVisibleMiniPlayer() {
binding.clMiniPlayer.visibility = View.VISIBLE binding.clMiniPlayer.visibility = View.VISIBLE
@@ -94,32 +86,48 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
} }
private fun connectPlayerService() { private fun connectPlayerService() {
if (mediaController != null || mediaControllerFuture != null) {
return
}
val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java) val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java)
val sessionToken = SessionToken(applicationContext, componentName) val sessionToken = SessionToken(applicationContext, componentName)
val mediaControllerFuture = val controllerFuture =
MediaController.Builder(applicationContext, sessionToken).buildAsync() MediaController.Builder(applicationContext, sessionToken).buildAsync()
mediaControllerFuture.addListener( mediaControllerFuture = controllerFuture
controllerFuture.addListener(
{ {
mediaController = mediaControllerFuture.get() try {
setupMediaController() if (mediaController != null) {
updateMediaMetadata(mediaController?.mediaMetadata) controllerFuture.get().release()
return@addListener
binding.ivPlayOrPause.setImageResource(
if (mediaController!!.isPlaying) {
R.drawable.ic_player_pause
} else {
R.drawable.ic_player_play
} }
)
binding.ivPlayOrPause.setOnClickListener { mediaController = controllerFuture.get()
mediaController?.let { setupMediaController()
if (it.playWhenReady) { updateMediaMetadata(mediaController?.mediaMetadata)
it.pause()
binding.ivPlayOrPause.setImageResource(
if (mediaController?.isPlaying == true) {
R.drawable.ic_player_pause
} else { } else {
it.play() R.drawable.ic_player_play
}
)
binding.ivPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
} else {
it.play()
}
} }
} }
} catch (throwable: Throwable) {
Logger.e(throwable, "Failed to connect player service")
} finally {
mediaControllerFuture = null
} }
}, },
ContextCompat.getMainExecutor(applicationContext) ContextCompat.getMainExecutor(applicationContext)
@@ -163,7 +171,10 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
} }
private fun deInitMiniPlayer() { private fun deInitMiniPlayer() {
handler.removeCallbacks(showMiniPlayerRunnable)
binding.clMiniPlayer.visibility = View.GONE binding.clMiniPlayer.visibility = View.GONE
mediaControllerFuture?.cancel(true)
mediaControllerFuture = null
mediaController?.release() mediaController?.release()
mediaController = null mediaController = null
} }
@@ -180,18 +191,24 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
bindData() bindData()
viewModel.getPlaylistDetail(playlistId) viewModel.getPlaylistDetail(playlistId)
SharedPreferenceManager.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
if (SharedPreferenceManager.isPlayerServiceRunning) { playerStateJob = lifecycleScope.launch {
initAndVisibleMiniPlayer() repeatOnLifecycle(Lifecycle.State.STARTED) {
} else { SharedPreferenceManager.isPlayerServiceRunningFlow.collect { isRunning ->
deInitMiniPlayer() if (isRunning) {
handler.removeCallbacks(showMiniPlayerRunnable)
handler.postDelayed(showMiniPlayerRunnable, 1500)
} else {
deInitMiniPlayer()
}
}
}
} }
} }
override fun onDestroy() { override fun onDestroy() {
deInitMiniPlayer() deInitMiniPlayer()
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) playerStateJob?.cancel()
super.onDestroy() super.onDestroy()
} }

View File

@@ -1,12 +1,10 @@
package kr.co.vividnext.sodalive.chat.talk.room package kr.co.vividnext.sodalive.chat.talk.room
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.content.edit
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -41,7 +39,6 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
private var characterId: Long = 0L private var characterId: Long = 0L
private var profileUrl: String = "" private var profileUrl: String = ""
private val prefsName = "chat_room_prefs"
private fun bgImageIdKey(roomId: Long) = "chat_bg_image_id_room_$roomId" private fun bgImageIdKey(roomId: Long) = "chat_bg_image_id_room_$roomId"
private lateinit var adapter: BgAdapter private lateinit var adapter: BgAdapter
@@ -87,8 +84,7 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
private fun loadData() { private fun loadData() {
// 초기 선택: 저장된 이미지 ID 사용 // 초기 선택: 저장된 이미지 ID 사용
val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) val savedId = ChatRoomPreferenceManager.getLong(bgImageIdKey(roomId), -1L)
val savedId = prefs.getLong(bgImageIdKey(roomId), -1L)
selectedId = if (savedId > 0) savedId else null selectedId = if (savedId > 0) savedId else null
items.clear() items.clear()
@@ -124,12 +120,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() {
} }
private fun saveAndApply(item: BgItem) { private fun saveAndApply(item: BgItem) {
val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) if (item.id > 0) {
prefs.edit { ChatRoomPreferenceManager.putLong(bgImageIdKey(roomId), item.id)
if (item.id > 0) putLong( } else {
bgImageIdKey(roomId), ChatRoomPreferenceManager.remove(bgImageIdKey(roomId))
item.id
) else remove(bgImageIdKey(roomId))
} }
(activity as? ChatRoomActivity)?.setChatBackground(item.url) (activity as? ChatRoomActivity)?.setChatBackground(item.url)
} }

View File

@@ -9,7 +9,6 @@ import android.text.TextWatcher
import android.view.View import android.view.View
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.core.content.edit
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
@@ -55,12 +54,10 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
// 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관) // 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관)
private var characterInfo: CharacterInfo? = null private var characterInfo: CharacterInfo? = null
// 5.4 SharedPreferences (안내 메시지 접힘 상태 저장)
private val prefs by lazy { getSharedPreferences("chat_room_prefs", MODE_PRIVATE) }
private fun noticePrefKey(roomId: Long) = "chat_notice_hidden_room_${roomId}" private fun noticePrefKey(roomId: Long) = "chat_notice_hidden_room_${roomId}"
private fun isNoticeHidden(): Boolean = prefs.getBoolean(noticePrefKey(roomId), false) private fun isNoticeHidden(): Boolean = ChatRoomPreferenceManager.getBoolean(noticePrefKey(roomId), false)
private fun setNoticeHidden(hidden: Boolean) { private fun setNoticeHidden(hidden: Boolean) {
prefs.edit { putBoolean(noticePrefKey(roomId), hidden) } ChatRoomPreferenceManager.putBoolean(noticePrefKey(roomId), hidden)
} }
override fun setupView() { override fun setupView() {
@@ -866,7 +863,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
} }
fun applyBackgroundVisibility() { fun applyBackgroundVisibility() {
val visible = prefs.getBoolean("chat_bg_visible_room_$roomId", true) val visible = ChatRoomPreferenceManager.getBoolean("chat_bg_visible_room_$roomId", true)
binding.ivBackgroundProfile.isVisible = visible binding.ivBackgroundProfile.isVisible = visible
binding.viewCharacterDim.isVisible = visible binding.viewCharacterDim.isVisible = visible
} }
@@ -885,7 +882,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
private fun bgImageIdPrefKey(): String = "chat_bg_image_id_room_$roomId" private fun bgImageIdPrefKey(): String = "chat_bg_image_id_room_$roomId"
private fun getSavedBackgroundImageId(): Long? { private fun getSavedBackgroundImageId(): Long? {
val id = prefs.getLong(bgImageIdPrefKey(), -1L) val id = ChatRoomPreferenceManager.getLong(bgImageIdPrefKey(), -1L)
return if (id > 0) id else null return if (id > 0) id else null
} }
@@ -912,9 +909,7 @@ class ChatRoomActivity : BaseActivity<ActivityChatRoomBinding>(
"chat_bg_image_id_room_$roomId", "chat_bg_image_id_room_$roomId",
noticePrefKey(roomId) noticePrefKey(roomId)
) )
prefs.edit { ChatRoomPreferenceManager.removeAll(keys)
keys.forEach { remove(it) }
}
} }
fun onResetChatRequested() { fun onResetChatRequested() {

View File

@@ -1,6 +1,5 @@
package kr.co.vividnext.sodalive.chat.talk.room package kr.co.vividnext.sodalive.chat.talk.room
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -11,7 +10,6 @@ import android.widget.RelativeLayout
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import com.google.android.material.switchmaterial.SwitchMaterial import com.google.android.material.switchmaterial.SwitchMaterial
import kr.co.vividnext.sodalive.R import kr.co.vividnext.sodalive.R
import androidx.core.content.edit
/** /**
* 채팅방 우측 상단 더보기 버튼 클릭 시 표시되는 전체화면 다이얼로그. * 채팅방 우측 상단 더보기 버튼 클릭 시 표시되는 전체화면 다이얼로그.
@@ -42,13 +40,12 @@ class ChatRoomMoreDialogFragment : DialogFragment() {
// 닫기 버튼 // 닫기 버튼
view.findViewById<ImageView>(R.id.iv_close)?.setOnClickListener { dismiss() } view.findViewById<ImageView>(R.id.iv_close)?.setOnClickListener { dismiss() }
val prefs = requireActivity().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val bgKey = bgPrefKey(roomId) val bgKey = bgPrefKey(roomId)
val switch = view.findViewById<SwitchMaterial>(R.id.sw_background) val switch = view.findViewById<SwitchMaterial>(R.id.sw_background)
switch?.isChecked = prefs.getBoolean(bgKey, true) switch?.isChecked = ChatRoomPreferenceManager.getBoolean(bgKey, true)
switch?.setOnCheckedChangeListener { _, isChecked -> switch?.setOnCheckedChangeListener { _, isChecked ->
prefs.edit { putBoolean(bgKey, isChecked) } ChatRoomPreferenceManager.putBoolean(bgKey, isChecked)
(activity as? ChatRoomActivity)?.applyBackgroundVisibility() (activity as? ChatRoomActivity)?.applyBackgroundVisibility()
} }
@@ -76,7 +73,6 @@ class ChatRoomMoreDialogFragment : DialogFragment() {
companion object { companion object {
private const val ARG_ROOM_ID = "arg_room_id" private const val ARG_ROOM_ID = "arg_room_id"
private const val PREFS_NAME = "chat_room_prefs"
private fun bgPrefKey(roomId: Long) = "chat_bg_visible_room_$roomId" private fun bgPrefKey(roomId: Long) = "chat_bg_visible_room_$roomId"
fun newInstance(roomId: Long): ChatRoomMoreDialogFragment { fun newInstance(roomId: Long): ChatRoomMoreDialogFragment {

View File

@@ -0,0 +1,190 @@
package kr.co.vividnext.sodalive.chat.talk.room
import android.content.Context
import androidx.annotation.VisibleForTesting
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.SharedPreferencesMigration
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object ChatRoomPreferenceManager {
private const val LEGACY_PREFERENCES_NAME = "chat_room_prefs"
private const val DATASTORE_FILE_NAME = "chat_room_preferences"
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
fun init(context: Context) {
if (initialized) {
return
}
synchronized(initLock) {
if (initialized) {
return
}
val appContext = context.applicationContext
if (!this::dataStore.isInitialized) {
dataStore = androidx.datastore.preferences.core.PreferenceDataStoreFactory.create(
migrations = listOf(SharedPreferencesMigration(appContext, LEGACY_PREFERENCES_NAME)),
produceFile = { appContext.preferencesDataStoreFile(DATASTORE_FILE_NAME) }
)
}
val initialPreferences = runBlocking {
dataStore.data.first()
}
updateState(initialPreferences)
initialized = true
observerJob?.cancel()
observerJob = appScope.launch {
dataStore.data.collect { preferences ->
updateState(preferences)
}
}
}
}
private fun updateState(preferences: Preferences) {
preferenceState.value = preferences.asMap().entries.associate { (key, value) ->
key.name to value
}
}
@VisibleForTesting(otherwise = VisibleForTesting.NONE)
fun resetForTest() {
synchronized(initLock) {
observerJob?.cancel()
observerJob = null
preferenceState.value = emptyMap()
initialized = false
}
}
fun getBoolean(key: String, defaultValue: Boolean): Boolean {
ensureInitialized()
return preferenceState.value[key] as? Boolean ?: defaultValue
}
fun putBoolean(key: String, value: Boolean) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
mutable[key] = value
mutable
}
appScope.launch {
dataStore.edit { preferences ->
preferences[booleanPreferencesKey(key)] = value
}
}
}
fun getLong(key: String, defaultValue: Long): Long {
ensureInitialized()
return preferenceState.value[key] as? Long ?: defaultValue
}
fun putLong(key: String, value: Long) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
mutable[key] = value
mutable
}
appScope.launch {
dataStore.edit { preferences ->
preferences[longPreferencesKey(key)] = value
}
}
}
fun getString(key: String, defaultValue: String): String {
ensureInitialized()
return preferenceState.value[key] as? String ?: defaultValue
}
fun putString(key: String, value: String) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
mutable[key] = value
mutable
}
appScope.launch {
dataStore.edit { preferences ->
preferences[stringPreferencesKey(key)] = value
}
}
}
fun remove(key: String) {
ensureInitialized()
preferenceState.update { state ->
val mutable = state.toMutableMap()
mutable.remove(key)
mutable
}
appScope.launch {
dataStore.edit { preferences ->
val prefKey = preferences.asMap().keys.firstOrNull { it.name == key } ?: return@edit
preferences.remove(prefKey)
}
}
}
fun removeAll(keys: Collection<String>) {
ensureInitialized()
if (keys.isEmpty()) {
return
}
preferenceState.update { state ->
val mutable = state.toMutableMap()
keys.forEach { key ->
mutable.remove(key)
}
mutable
}
appScope.launch {
dataStore.edit { preferences ->
val mapKeys = preferences.asMap().keys
keys.forEach { keyName ->
val prefKey = mapKeys.firstOrNull { it.name == keyName } ?: return@forEach
preferences.remove(prefKey)
}
}
}
}
private fun ensureInitialized() {
check(initialized) { "ChatRoomPreferenceManager is not initialized." }
}
}

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 package kr.co.vividnext.sodalive.common
import android.content.Context import android.content.Context
import android.content.SharedPreferences import androidx.annotation.VisibleForTesting
import androidx.preference.PreferenceManager 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.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import kr.co.vividnext.sodalive.settings.notification.MemberRole 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 { 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) { fun init(context: Context) {
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) if (initialized) {
} return
}
fun registerOnSharedPreferenceChangeListener( synchronized(initLock) {
listener: SharedPreferences.OnSharedPreferenceChangeListener if (initialized) {
) { return
sharedPreferences.registerOnSharedPreferenceChangeListener(listener) }
}
fun unregisterOnSharedPreferenceChangeListener( dataStore = AppPreferencesDataStoreProvider.get(context.applicationContext)
listener: SharedPreferences.OnSharedPreferenceChangeListener val initialPreferences = runBlocking {
) { dataStore.data.first()
sharedPreferences.unregisterOnSharedPreferenceChangeListener(listener) }
} updateState(initialPreferences)
initialized = true
fun clear() { observerJob?.cancel()
sharedPreferences.edit { editor -> observerJob = appScope.launch {
sharedPreferences.all.keys dataStore.data.collect { preferences ->
.filterNot { it == Constants.PREF_PUSH_TOKEN } updateState(preferences)
.forEach { editor.remove(it) } }
}
} }
} }
private inline fun SharedPreferences.edit(operation: (SharedPreferences.Editor) -> Unit) { private fun updateState(preferences: Preferences) {
val editor = this.edit() preferenceState.value = preferences.asMap().entries.associate { (key, value) ->
operation(editor) key.name to value
editor.apply() }
} }
private operator fun SharedPreferences.set(key: String, value: Any?) { @VisibleForTesting(otherwise = VisibleForTesting.NONE)
when (value) { fun resetForTest() {
is String? -> edit { it.putString(key, value) } synchronized(initLock) {
is Int -> edit { it.putInt(key, value) } observerJob?.cancel()
is Boolean -> edit { it.putBoolean(key, value) } observerJob = null
is Float -> edit { it.putFloat(key, value) } preferenceState.value = emptyMap()
is Long -> edit { it.putLong(key, value) } initialized = false
else -> throw UnsupportedOperationException("Error") }
}
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") @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) { return when (defaultValue) {
is String, null -> getString(key, defaultValue as? String) as T is String, null -> (value as? String ?: defaultValue as? String) as T
is Int -> getInt(key, defaultValue as? Int ?: -1) as T is Int -> (value as? Int ?: defaultValue) as T
is Boolean -> getBoolean(key, defaultValue as? Boolean ?: false) as T is Boolean -> (value as? Boolean ?: defaultValue) as T
is Float -> getFloat(key, defaultValue as? Float ?: -1f) as T is Float -> (value as? Float ?: defaultValue) as T
is Long -> getLong(key, defaultValue as? Long ?: -1) as T is Long -> (value as? Long ?: defaultValue) as T
else -> throw UnsupportedOperationException("Error") 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 var token: String
get() = sharedPreferences[Constants.PREF_TOKEN, ""] get() = getPreference(Constants.PREF_TOKEN, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_TOKEN] = value setPreference(Constants.PREF_TOKEN, value)
} }
var userId: Long var userId: Long
get() = sharedPreferences[Constants.PREF_USER_ID, 0] get() = getPreference(Constants.PREF_USER_ID, 0L)
set(value) { set(value) {
sharedPreferences[Constants.PREF_USER_ID] = value setPreference(Constants.PREF_USER_ID, value)
} }
var nickname: String var nickname: String
get() = sharedPreferences[Constants.PREF_NICKNAME, ""] get() = getPreference(Constants.PREF_NICKNAME, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_NICKNAME] = value setPreference(Constants.PREF_NICKNAME, value)
} }
var email: String var email: String
get() = sharedPreferences[Constants.PREF_EMAIL, ""] get() = getPreference(Constants.PREF_EMAIL, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_EMAIL] = value setPreference(Constants.PREF_EMAIL, value)
} }
var profileImage: String var profileImage: String
get() = sharedPreferences[Constants.PREF_PROFILE_IMAGE, ""] get() = getPreference(Constants.PREF_PROFILE_IMAGE, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_PROFILE_IMAGE] = value setPreference(Constants.PREF_PROFILE_IMAGE, value)
} }
var can: Int var can: Int
get() = sharedPreferences[Constants.PREF_CAN, 0] get() = getPreference(Constants.PREF_CAN, 0)
set(value) { set(value) {
sharedPreferences[Constants.PREF_CAN] = value setPreference(Constants.PREF_CAN, value)
} }
var point: Int var point: Int
get() = sharedPreferences[Constants.PREF_POINT, 0] get() = getPreference(Constants.PREF_POINT, 0)
set(value) { set(value) {
sharedPreferences[Constants.PREF_POINT] = value setPreference(Constants.PREF_POINT, value)
} }
var role: String var role: String
get() = sharedPreferences[Constants.PREF_USER_ROLE, MemberRole.USER.name] get() = getPreference(Constants.PREF_USER_ROLE, MemberRole.USER.name)
set(value) { set(value) {
sharedPreferences[Constants.PREF_USER_ROLE] = value setPreference(Constants.PREF_USER_ROLE, value)
} }
var isAuth: Boolean var isAuth: Boolean
get() = sharedPreferences[Constants.PREF_IS_ADULT, false] get() = getPreference(Constants.PREF_IS_ADULT, false)
set(value) { set(value) {
sharedPreferences[Constants.PREF_IS_ADULT] = value setPreference(Constants.PREF_IS_ADULT, value)
} }
var isAuditionNotification: Boolean var isAuditionNotification: Boolean
get() = sharedPreferences[Constants.PREF_IS_AUDITION_NOTIFICATION, false] get() = getPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, false)
set(value) { set(value) {
sharedPreferences[Constants.PREF_IS_AUDITION_NOTIFICATION] = value setPreference(Constants.PREF_IS_AUDITION_NOTIFICATION, value)
} }
var isAdultContentVisible: Boolean var isAdultContentVisible: Boolean
get() = sharedPreferences[Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true] get() = getPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, true)
set(value) { set(value) {
sharedPreferences[Constants.PREF_IS_ADULT_CONTENT_VISIBLE] = value setPreference(Constants.PREF_IS_ADULT_CONTENT_VISIBLE, value)
} }
var contentPreference: Int var contentPreference: Int
get() = sharedPreferences[Constants.PREF_CONTENT_PREFERENCE, 0] get() = getPreference(Constants.PREF_CONTENT_PREFERENCE, 0)
set(value) { set(value) {
sharedPreferences[Constants.PREF_CONTENT_PREFERENCE] = value setPreference(Constants.PREF_CONTENT_PREFERENCE, value)
} }
var pushToken: String var pushToken: String
get() = sharedPreferences[Constants.PREF_PUSH_TOKEN, ""] get() = getPreference(Constants.PREF_PUSH_TOKEN, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_PUSH_TOKEN] = value setPreference(Constants.PREF_PUSH_TOKEN, value)
} }
var isContentPlayLoop: Boolean var isContentPlayLoop: Boolean
get() = sharedPreferences[Constants.PREF_IS_CONTENT_PLAY_LOOP, false] get() = getPreference(Constants.PREF_IS_CONTENT_PLAY_LOOP, false)
set(value) { set(value) {
sharedPreferences[Constants.PREF_IS_CONTENT_PLAY_LOOP] = value setPreference(Constants.PREF_IS_CONTENT_PLAY_LOOP, value)
} }
var notShowingEventPopupId: Long 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) { set(value) {
sharedPreferences[Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID] = value setPreference(Constants.PREF_NOT_SHOWING_EVENT_POPUP_ID, value)
} }
var isViewedOnboardingTutorial: Boolean 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) { set(value) {
sharedPreferences[Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL] = value setPreference(Constants.PREF_IS_VIEWED_ON_BOARDING_TUTORIAL, value)
} }
var noChatRoomList: List<Long> var noChatRoomList: List<Long>
get() { get() {
val list = sharedPreferences[Constants.PREF_NO_CHAT_ROOM, ""] val list = getPreference(Constants.PREF_NO_CHAT_ROOM, "")
val gson = Gson() val gson = Gson()
val listType = object : TypeToken<List<Long>>() {}.type val listType = object : TypeToken<List<Long>>() {}.type
val myList = gson.fromJson<List<Long>>(list, listType) val myList = gson.fromJson<List<Long>>(list, listType)
@@ -170,54 +273,60 @@ object SharedPreferenceManager {
set(value) { set(value) {
val gson = Gson() val gson = Gson()
val listJson = gson.toJson(value) val listJson = gson.toJson(value)
sharedPreferences[Constants.PREF_NO_CHAT_ROOM] = listJson setPreference(Constants.PREF_NO_CHAT_ROOM, listJson)
} }
var isPlayerServiceRunning: Boolean var isPlayerServiceRunning: Boolean
get() = sharedPreferences[Constants.PREF_IS_PLAYER_SERVICE_RUNNING, false] get() = getPreference(Constants.PREF_IS_PLAYER_SERVICE_RUNNING, false)
set(value) { set(value) {
sharedPreferences[Constants.PREF_IS_PLAYER_SERVICE_RUNNING] = value setPreference(Constants.PREF_IS_PLAYER_SERVICE_RUNNING, value)
} }
var marketingPid: String var marketingPid: String
get() = sharedPreferences[Constants.PREF_MARKETING_PID, ""] get() = getPreference(Constants.PREF_MARKETING_PID, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_MARKETING_PID] = value setPreference(Constants.PREF_MARKETING_PID, value)
} }
var marketingUtmSource: String var marketingUtmSource: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_SOURCE, ""] get() = getPreference(Constants.PREF_MARKETING_UTM_SOURCE, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_SOURCE] = value setPreference(Constants.PREF_MARKETING_UTM_SOURCE, value)
} }
var marketingUtmMedium: String var marketingUtmMedium: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_MEDIUM, ""] get() = getPreference(Constants.PREF_MARKETING_UTM_MEDIUM, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_MEDIUM] = value setPreference(Constants.PREF_MARKETING_UTM_MEDIUM, value)
} }
var marketingUtmCampaign: String var marketingUtmCampaign: String
get() = sharedPreferences[Constants.PREF_MARKETING_UTM_CAMPAIGN, ""] get() = getPreference(Constants.PREF_MARKETING_UTM_CAMPAIGN, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_MARKETING_UTM_CAMPAIGN] = value setPreference(Constants.PREF_MARKETING_UTM_CAMPAIGN, value)
} }
var marketingLinkValue: String var marketingLinkValue: String
get() = sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE, ""] get() = getPreference(Constants.PREF_MARKETING_LINK_VALUE, "")
set(value) { set(value) {
sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE] = value setPreference(Constants.PREF_MARKETING_LINK_VALUE, value)
} }
var marketingLinkValueId: Long var marketingLinkValueId: Long
get() = sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE_ID, 0L] get() = getPreference(Constants.PREF_MARKETING_LINK_VALUE_ID, 0L)
set(value) { set(value) {
sharedPreferences[Constants.PREF_MARKETING_LINK_VALUE_ID] = value setPreference(Constants.PREF_MARKETING_LINK_VALUE_ID, value)
} }
var alreadyTrackingAppLaunch: Boolean var alreadyTrackingAppLaunch: Boolean
get() = sharedPreferences[Constants.PREF_ALREADY_TRACKING_APP_LAUNCH, true] get() = getPreference(Constants.PREF_ALREADY_TRACKING_APP_LAUNCH, true)
set(value) { 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)
} }
} }

View File

@@ -1,7 +1,6 @@
package kr.co.vividnext.sodalive.home package kr.co.vividnext.sodalive.home
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Rect import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@@ -13,6 +12,9 @@ import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -68,6 +70,8 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::inflate) { class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::inflate) {
@@ -95,34 +99,17 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val preferenceChangeListener = override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> super.onViewCreated(view, savedInstanceState)
// 특정 키에 대한 값이 변경될 때 UI 업데이트
if (key == Constants.PREF_USER_ROLE) { viewLifecycleOwner.lifecycleScope.launch {
if ( viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
sharedPreferences.getString( SharedPreferenceManager.roleFlow.collect { role ->
key, renderUploadContentByRole(role)
MemberRole.USER.name
) == MemberRole.CREATOR.name
) {
binding.llUploadContent.visibility = View.VISIBLE
binding.llUploadContent.setOnClickListener {
startActivity(
Intent(
requireActivity(),
AudioContentUploadActivity::class.java
)
)
}
} else {
binding.llUploadContent.visibility = View.GONE
} }
} }
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
SharedPreferenceManager.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
setupView() setupView()
bindData() bindData()
@@ -130,26 +117,13 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
} }
override fun onDestroyView() { override fun onDestroyView() {
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
super.onDestroyView() super.onDestroyView()
} }
private fun setupView() { private fun setupView() {
loadingDialog = LoadingDialog(requireActivity(), layoutInflater) loadingDialog = LoadingDialog(requireActivity(), layoutInflater)
if (SharedPreferenceManager.role == MemberRole.CREATOR.name) { renderUploadContentByRole(SharedPreferenceManager.role)
binding.llUploadContent.visibility = View.VISIBLE
binding.llUploadContent.setOnClickListener {
startActivity(
Intent(
requireActivity(),
AudioContentUploadActivity::class.java
)
)
}
} else {
binding.llUploadContent.visibility = View.GONE
}
if (SharedPreferenceManager.token.isNotBlank()) { if (SharedPreferenceManager.token.isNotBlank()) {
binding.llShortIcon.visibility = View.VISIBLE binding.llShortIcon.visibility = View.VISIBLE
@@ -199,6 +173,23 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(FragmentHomeBinding::infl
setupRecommendContent() setupRecommendContent()
} }
private fun renderUploadContentByRole(role: String) {
if (role == MemberRole.CREATOR.name) {
binding.llUploadContent.visibility = View.VISIBLE
binding.llUploadContent.setOnClickListener {
startActivity(
Intent(
requireActivity(),
AudioContentUploadActivity::class.java
)
)
}
return
}
binding.llUploadContent.visibility = View.GONE
}
private fun setupLiveView() { private fun setupLiveView() {
liveAdapter = HomeLiveAdapter { liveAdapter = HomeLiveAdapter {
ensureLoginAndAdultAuth(isAdult = it.isAdult) { ensureLoginAndAdultAuth(isAdult = it.isAdult) {

View File

@@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.live
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences
import android.graphics.Rect import android.graphics.Rect
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@@ -15,6 +14,9 @@ import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -72,6 +74,8 @@ import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.TimeZone import java.util.TimeZone
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.math.roundToInt import kotlin.math.roundToInt
@UnstableApi @UnstableApi
@@ -90,27 +94,6 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
private var message = "" private var message = ""
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val preferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
// 특정 키에 대한 값이 변경될 때 UI 업데이트
if (key == Constants.PREF_USER_ROLE) {
if (
sharedPreferences.getString(
key,
MemberRole.USER.name
) == MemberRole.CREATOR.name
) {
binding.llMakeLive.visibility = View.VISIBLE
binding.llMakeLive.setOnClickListener {
val intent = Intent(requireContext(), LiveRoomCreateActivity::class.java)
activityResultLauncher.launch(intent)
}
} else {
binding.llMakeLive.visibility = View.GONE
}
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -131,7 +114,14 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
SharedPreferenceManager.registerOnSharedPreferenceChangeListener(preferenceChangeListener)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
SharedPreferenceManager.roleFlow.collect { role ->
renderMakeLiveByRole(role)
}
}
}
setupView() setupView()
@@ -140,7 +130,6 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
} }
override fun onDestroyView() { override fun onDestroyView() {
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
super.onDestroyView() super.onDestroyView()
} }
@@ -159,17 +148,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
} }
} }
binding.llMakeLive.visibility = renderMakeLiveByRole(SharedPreferenceManager.role)
if (SharedPreferenceManager.role == MemberRole.CREATOR.name) {
View.VISIBLE
} else {
View.GONE
}
binding.llMakeLive.setOnClickListener {
val intent = Intent(requireContext(), LiveRoomCreateActivity::class.java)
activityResultLauncher.launch(intent)
}
setupToolbar() setupToolbar()
setupLiveNow() setupLiveNow()
@@ -181,6 +160,19 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(FragmentLiveBinding::infl
setupLiveReservation() setupLiveReservation()
} }
private fun renderMakeLiveByRole(role: String) {
if (role == MemberRole.CREATOR.name) {
binding.llMakeLive.visibility = View.VISIBLE
binding.llMakeLive.setOnClickListener {
val intent = Intent(requireContext(), LiveRoomCreateActivity::class.java)
activityResultLauncher.launch(intent)
}
return
}
binding.llMakeLive.visibility = View.GONE
}
private fun setupToolbar() { private fun setupToolbar() {
if (SharedPreferenceManager.token.isNotBlank()) { if (SharedPreferenceManager.token.isNotBlank()) {
binding.llShortIcon.visibility = View.VISIBLE binding.llShortIcon.visibility = View.VISIBLE

View File

@@ -7,7 +7,6 @@ import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
@@ -16,6 +15,9 @@ import android.os.Looper
import android.view.View import android.view.View
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat import androidx.core.content.res.ResourcesCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player import androidx.media3.common.Player
@@ -24,6 +26,7 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import coil.load import coil.load
import coil.transform.RoundedCornersTransformation import coil.transform.RoundedCornersTransformation
import com.google.common.util.concurrent.ListenableFuture
import com.google.firebase.messaging.FirebaseMessaging import com.google.firebase.messaging.FirebaseMessaging
import com.gun0912.tedpermission.PermissionListener import com.gun0912.tedpermission.PermissionListener
import com.gun0912.tedpermission.normal.TedPermission import com.gun0912.tedpermission.normal.TedPermission
@@ -52,6 +55,9 @@ import kr.co.vividnext.sodalive.settings.notification.NotificationSettingsDialog
import kr.co.vividnext.sodalive.user.login.LoginActivity import kr.co.vividnext.sodalive.user.login.LoginActivity
import org.koin.android.ext.android.inject import org.koin.android.ext.android.inject
import java.util.Locale import java.util.Locale
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
@UnstableApi @UnstableApi
class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::inflate) { class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::inflate) {
@@ -63,25 +69,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
private lateinit var notificationSettingsDialog: NotificationSettingsDialog private lateinit var notificationSettingsDialog: NotificationSettingsDialog
private var mediaController: MediaController? = null private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() }
private val audioContentReceiver = AudioContentReceiver() private val audioContentReceiver = AudioContentReceiver()
private var playerStateJob: Job? = null
private val preferenceChangeListener =
SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key ->
// 특정 키에 대한 값이 변경될 때 UI 업데이트
if (key == Constants.PREF_IS_PLAYER_SERVICE_RUNNING) {
if (sharedPreferences.getBoolean(key, false)) {
handler.postDelayed(
{
initAndVisibleMiniPlayer()
},
1500
)
} else {
deInitMiniPlayer()
}
}
}
private fun initAndVisibleMiniPlayer() { private fun initAndVisibleMiniPlayer() {
binding.clMiniPlayer.visibility = View.VISIBLE binding.clMiniPlayer.visibility = View.VISIBLE
@@ -97,32 +89,48 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
} }
private fun connectPlayerService() { private fun connectPlayerService() {
if (mediaController != null || mediaControllerFuture != null) {
return
}
val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java) val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java)
val sessionToken = SessionToken(applicationContext, componentName) val sessionToken = SessionToken(applicationContext, componentName)
val mediaControllerFuture = val controllerFuture =
MediaController.Builder(applicationContext, sessionToken).buildAsync() MediaController.Builder(applicationContext, sessionToken).buildAsync()
mediaControllerFuture.addListener( mediaControllerFuture = controllerFuture
controllerFuture.addListener(
{ {
mediaController = mediaControllerFuture.get() try {
setupMediaController() if (mediaController != null) {
updateMediaMetadata(mediaController?.mediaMetadata) controllerFuture.get().release()
return@addListener
binding.ivPlayerPlayOrPause.setImageResource(
if (mediaController!!.isPlaying) {
R.drawable.ic_player_pause
} else {
R.drawable.ic_player_play
} }
)
binding.ivPlayerPlayOrPause.setOnClickListener { mediaController = controllerFuture.get()
mediaController?.let { setupMediaController()
if (it.playWhenReady) { updateMediaMetadata(mediaController?.mediaMetadata)
it.pause()
binding.ivPlayerPlayOrPause.setImageResource(
if (mediaController?.isPlaying == true) {
R.drawable.ic_player_pause
} else { } else {
it.play() R.drawable.ic_player_play
}
)
binding.ivPlayerPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
} else {
it.play()
}
} }
} }
} catch (throwable: Throwable) {
Logger.e(throwable, "Failed to connect player service")
} finally {
mediaControllerFuture = null
} }
}, },
ContextCompat.getMainExecutor(applicationContext) ContextCompat.getMainExecutor(applicationContext)
@@ -166,7 +174,10 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
} }
private fun deInitMiniPlayer() { private fun deInitMiniPlayer() {
handler.removeCallbacks(showMiniPlayerRunnable)
binding.clMiniPlayer.visibility = View.GONE binding.clMiniPlayer.visibility = View.GONE
mediaControllerFuture?.cancel(true)
mediaControllerFuture = null
mediaController?.release() mediaController?.release()
mediaController = null mediaController = null
} }
@@ -202,13 +213,17 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
updatePidAndGaid() updatePidAndGaid()
getEventPopup() getEventPopup()
SharedPreferenceManager.registerOnSharedPreferenceChangeListener( playerStateJob = lifecycleScope.launch {
preferenceChangeListener repeatOnLifecycle(Lifecycle.State.STARTED) {
) SharedPreferenceManager.isPlayerServiceRunningFlow.collect { isRunning ->
if (SharedPreferenceManager.isPlayerServiceRunning) { if (isRunning) {
initAndVisibleMiniPlayer() handler.removeCallbacks(showMiniPlayerRunnable)
} else { handler.postDelayed(showMiniPlayerRunnable, 1500)
deInitMiniPlayer() } else {
deInitMiniPlayer()
}
}
}
} }
handler.postDelayed({ executeDeeplink(intent) }, 1000) handler.postDelayed({ executeDeeplink(intent) }, 1000)
@@ -217,7 +232,7 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
override fun onDestroy() { override fun onDestroy() {
deInitMiniPlayer() deInitMiniPlayer()
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) playerStateJob?.cancel()
super.onDestroy() super.onDestroy()
} }

View File

@@ -1,21 +1,14 @@
package kr.co.vividnext.sodalive.settings.language package kr.co.vividnext.sodalive.settings.language
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Build import android.os.Build
import androidx.core.content.edit import kr.co.vividnext.sodalive.common.SharedPreferenceManager
import androidx.preference.PreferenceManager
object LanguageManager { object LanguageManager {
const val LANG_KO = "ko" const val LANG_KO = "ko"
const val LANG_EN = "en" const val LANG_EN = "en"
const val LANG_JA = "ja" const val LANG_JA = "ja"
private const val PREF_KEY_APP_LANGUAGE = "pref_app_language_code"
private fun prefs(context: Context): SharedPreferences =
PreferenceManager.getDefaultSharedPreferences(context.applicationContext)
fun isSupported(code: String): Boolean = when (code) { fun isSupported(code: String): Boolean = when (code) {
LANG_KO, LANG_EN, LANG_JA -> true LANG_KO, LANG_EN, LANG_JA -> true
else -> false else -> false
@@ -25,8 +18,9 @@ object LanguageManager {
* 사용자가 앱 내에서 명시적으로 선택한 언어 코드를 반환한다. 없으면 null. * 사용자가 앱 내에서 명시적으로 선택한 언어 코드를 반환한다. 없으면 null.
*/ */
fun getUserSelectedLanguageOrNull(context: Context): String? { fun getUserSelectedLanguageOrNull(context: Context): String? {
val code = prefs(context).getString(PREF_KEY_APP_LANGUAGE, null) SharedPreferenceManager.init(context.applicationContext)
return code?.takeIf { it.isNotBlank() } val code = SharedPreferenceManager.appLanguageCode
return code.takeIf { it.isNotBlank() }
} }
/** /**
@@ -64,6 +58,7 @@ object LanguageManager {
fun setSelectedLanguage(context: Context, code: String) { fun setSelectedLanguage(context: Context, code: String) {
val normalized = if (isSupported(code)) code else LANG_KO val normalized = if (isSupported(code)) code else LANG_KO
prefs(context).edit { putString(PREF_KEY_APP_LANGUAGE, normalized) } SharedPreferenceManager.init(context.applicationContext)
SharedPreferenceManager.appLanguageCode = normalized
} }
} }

View File

@@ -0,0 +1,57 @@
# DataStore 전환 및 SharedPreferences 마이그레이션
- [x] 공식 문서의 DataStore 권고 문구 확인 및 근거 정리
- [x] 프로젝트 내 SharedPreferences 사용 지점 식별
- [x] `SharedPreferenceManager`를 DataStore 기반으로 전환
- [x] `LanguageManager`를 DataStore 기반 읽기/쓰기로 전환
- [x] 채팅방 `chat_room_prefs`를 DataStore + 마이그레이션으로 전환
- [x] 앱 초기화 경로에 DataStore 초기화 반영
- [x] 진단/테스트/빌드 검증 결과 기록
- [x] 핵심 런타임 회귀 자동 테스트(androidTest) 추가
## 검증 기록
### 2026-03-09
- 무엇/왜/어떻게: Android 공식 문구를 확인한 뒤, 기본 설정 저장소(`SharedPreferenceManager`)와 채팅방 전용 저장소(`chat_room_prefs`)를 각각 DataStore로 전환하고 `SharedPreferencesMigration`으로 기존 설치 기기의 데이터가 손실 없이 이관되도록 구현했다. 기존 호출부 대량 수정을 피하기 위해 기존 매니저 API를 유지하고 내부 저장소만 교체했다.
- 공식 문서 근거: "If you're using `SharedPreferences` to store data, consider migrating to DataStore instead." (`App Architecture: Data Layer - DataStore - Android Developers`, https://developer.android.com/topic/libraries/architecture/datastore)
- 실행 명령: `lsp_diagnostics` (변경 Kotlin 파일 8개)
- 결과: 현재 실행 환경에 Kotlin LSP 서버가 없어 진단 불가(`No LSP server configured for extension: .kt`).
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL` (단위 테스트/디버그 빌드 성공).
- 실행 명령: Oracle 리뷰(`bg_0bc7de61`)
- 결과: 리스너 콜백 쓰레드 호환성 리스크(백그라운드 스레드에서 UI 리스너 호출 가능)와 읽기 전용 `SharedPreferences` 뷰의 no-op `edit()` 리스크를 확인했고, `SharedPreferenceManager`에서 메인 스레드 디스패치 및 fail-fast `edit()` 예외 처리로 반영했다.
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug` (Oracle 피드백 반영 후 재검증)
- 결과: `BUILD SUCCESSFUL`.
- 실행 명령: `./gradlew :app:lintDebug`
- 결과: 실패. 기존 프로젝트 이슈(`AndroidManifest.xml``MissingClass(com.facebook.FacebookActivity)` 포함 18 errors, 577 warnings)로 중단되었고, 재실행에서도 동일 결과다.
- 실행 명령: `./gradlew :app:ktlintCheck`
- 결과: 실패. 기존 코드베이스 전반의 스타일 이슈(다수 파일)로 `:app:ktlintMainSourceSetCheck` 실패.
- 무엇/왜/어떻게: 요청에 따라 "첫 접근 시 이관" 지점 주석을 추가했다. `AppPreferencesDataStoreProvider``SharedPreferencesMigration` 등록부와 `SharedPreferenceManager`/`ChatRoomPreferenceManager``dataStore.data.first()` 트리거 지점에 주석을 배치해, 언제 이관이 실행되는지 코드를 읽는 즉시 파악할 수 있게 했다.
- 실행 명령: `./gradlew :app:assembleDebug :app:testDebugUnitTest`
- 결과: `BUILD SUCCESSFUL`.
- 무엇/왜/어떻게: 사용자 요청에 맞춰 "전체 일괄 치환" 대신 값 변화 반응 지점과 즉시 사용 안정성이 중요한 지점을 우선 리팩터링했다. `SharedPreferenceManager`/`ChatRoomPreferenceManager` 내부 `runBlocking`을 제거하고 비동기 수집 + 메모리 캐시 기반으로 변경했으며, 기존 `OnSharedPreferenceChangeListener` 의존 화면 4개를 `Flow` 수집으로 전환했다.
- 반응형 전환 파일: `MainActivity`, `HomeFragment`, `LiveFragment`, `AudioContentPlaylistDetailActivity`에서 `isPlayerServiceRunningFlow`/`roleFlow``repeatOnLifecycle`로 수집해 UI를 갱신하도록 변경.
- 실행 명령: `./gradlew :app:assembleDebug :app:testDebugUnitTest` (비동기 리팩터링 반영 후)
- 결과: `BUILD SUCCESSFUL`.
- 실행 명령: `./gradlew :app:lintDebug` (최종)
- 결과: 실패. 기존 프로젝트 이슈(`AndroidManifest.xml``MissingClass(com.facebook.FacebookActivity)` 포함 18 errors, 577 warnings)로 동일하게 중단.
### 2026-03-11
- 무엇/왜/어떻게: 핵심 런타임 회귀 2건을 수정했다. (1) `SharedPreferenceManager`/`ChatRoomPreferenceManager`가 초기 캐시 반영 전에 기본값을 반환하던 문제를 막기 위해 `init()`에서 `dataStore.data.first()`로 초기 스냅샷을 먼저 로딩한 뒤 `initialized`를 설정하도록 조정했다. (2) `repeatOnLifecycle(STARTED)` 재수집 시 미니플레이어가 중복 연결되던 문제를 막기 위해 `MainActivity`/`AudioContentPlaylistDetailActivity``mediaControllerFuture` 가드와 지연 실행 runnable 정리(`removeCallbacks`)를 추가했다.
- 반영 파일: `SharedPreferenceManager.kt`, `ChatRoomPreferenceManager.kt`, `MainActivity.kt`, `AudioContentPlaylistDetailActivity.kt`.
- 실행 명령: `lsp_diagnostics` (변경 Kotlin 파일 4개)
- 결과: 현재 실행 환경에 Kotlin LSP 서버가 없어 진단 불가(`No LSP server configured for extension: .kt`).
- 실행 명령: `./gradlew --stop && ./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`.
- 실행 명령: `./gradlew :app:lintDebug`
- 결과: 실패. 기존 프로젝트 이슈(`AndroidManifest.xml``MissingClass(com.facebook.FacebookActivity)` 포함 18 errors, 577 warnings)로 동일하게 중단.
- 무엇/왜/어떻게: 수동 재현에 의존하지 않도록 런타임 회귀 자동 검증을 위한 계측 테스트를 추가했다. `DataStoreRuntimeRegressionTest`에서 초기 스냅샷 재초기화 경로를 검증하고, `MiniPlayerConnectionGuardTest`에서 `mediaControllerFuture`가 이미 존재할 때 `connectPlayerService()`가 조기 반환되는 가드를 검증한다. 이를 위해 `androidTest` 실행 환경(`testInstrumentationRunner`, `androidTestImplementation`)을 설정했고, 테스트 재초기화를 위해 `SharedPreferenceManager`/`ChatRoomPreferenceManager`/`AppPreferencesDataStoreProvider``resetForTest()` 훅을 추가했다.
- 반영 파일: `app/build.gradle`, `AppPreferencesDataStoreProvider.kt`, `SharedPreferenceManager.kt`, `ChatRoomPreferenceManager.kt`, `app/src/androidTest/java/kr/co/vividnext/sodalive/runtime/DataStoreRuntimeRegressionTest.kt`, `app/src/androidTest/java/kr/co/vividnext/sodalive/runtime/MiniPlayerConnectionGuardTest.kt`.
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug :app:assembleDebugAndroidTest`
- 결과: `BUILD SUCCESSFUL`.
- 실행 명령: `./gradlew :app:connectedDebugAndroidTest`
- 결과: `BUILD SUCCESSFUL` (SM-G960N - 10, 4 tests passed).
- 실행 명령: `./gradlew :app:testDebugUnitTest :app:assembleDebug`
- 결과: `BUILD SUCCESSFUL`.
- 실행 명령: `./gradlew :app:lintDebug`
- 결과: 실패. 기존 프로젝트 이슈(`AndroidManifest.xml``MissingClass(com.facebook.FacebookActivity)` 포함 18 errors, 577 warnings)로 동일하게 중단.