diff --git a/app/build.gradle b/app/build.gradle index 68017b4f..7610b5cb 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -65,6 +65,7 @@ android { targetSdk 35 versionCode 224 versionName "1.51.1" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -165,6 +166,7 @@ dependencies { exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel' exclude group: 'androidx.lifecycle', module: 'lifecycle-viewmodel-ktx' } + implementation "androidx.datastore:datastore-preferences:1.2.0" // Gson 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.kotlin:mockito-kotlin:6.1.0' 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' } diff --git a/app/src/androidTest/java/kr/co/vividnext/sodalive/runtime/DataStoreRuntimeRegressionTest.kt b/app/src/androidTest/java/kr/co/vividnext/sodalive/runtime/DataStoreRuntimeRegressionTest.kt new file mode 100644 index 00000000..a0790639 --- /dev/null +++ b/app/src/androidTest/java/kr/co/vividnext/sodalive/runtime/DataStoreRuntimeRegressionTest.kt @@ -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 { + val field = ChatRoomPreferenceManager::class.java.getDeclaredField("dataStore") + field.isAccessible = true + return field.get(ChatRoomPreferenceManager) as DataStore + } + + companion object { + private const val PREF_APP_LANGUAGE_CODE = "pref_app_language_code" + } +} diff --git a/app/src/androidTest/java/kr/co/vividnext/sodalive/runtime/MiniPlayerConnectionGuardTest.kt b/app/src/androidTest/java/kr/co/vividnext/sodalive/runtime/MiniPlayerConnectionGuardTest.kt new file mode 100644 index 00000000..cbd6c122 --- /dev/null +++ b/app/src/androidTest/java/kr/co/vividnext/sodalive/runtime/MiniPlayerConnectionGuardTest.kt @@ -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() + setPrivateField(activity, "mediaControllerFuture", sentinel) + + invokePrivateNoArg(activity, "connectPlayerService") + + @Suppress("UNCHECKED_CAST") + val after = getPrivateField(activity, "mediaControllerFuture") as? ListenableFuture + assertSame(sentinel, after) + } + + @Test + fun playlistDetailActivity_skipsConnectingWhenFutureAlreadyExists() { + val activity = createOnMainThread { AudioContentPlaylistDetailActivity() } + val sentinel = SettableFuture.create() + setPrivateField(activity, "mediaControllerFuture", sentinel) + + invokePrivateNoArg(activity, "connectPlayerService") + + @Suppress("UNCHECKED_CAST") + val after = getPrivateField(activity, "mediaControllerFuture") as? ListenableFuture + 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 createOnMainThread(factory: () -> T): T { + var instance: Any? = null + InstrumentationRegistry.getInstrumentation().runOnMainSync { + instance = factory() + } + @Suppress("UNCHECKED_CAST") + return instance as T + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/app/SodaLiveApp.kt b/app/src/main/java/kr/co/vividnext/sodalive/app/SodaLiveApp.kt index 987c49b8..e03f5d15 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/app/SodaLiveApp.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/app/SodaLiveApp.kt @@ -16,6 +16,7 @@ import com.orhanobut.logger.AndroidLogAdapter import com.orhanobut.logger.Logger import kr.co.vividnext.sodalive.BuildConfig 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.SharedPreferenceManager import kr.co.vividnext.sodalive.common.SodaLiveApplicationHolder @@ -40,6 +41,7 @@ class SodaLiveApp : Application(), DefaultLifecycleObserver { SodaLiveApplicationHolder.init(this) SharedPreferenceManager.init(applicationContext) + ChatRoomPreferenceManager.init(applicationContext) ImageLoaderProvider.init(applicationContext) diff --git a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/playlist/detail/AudioContentPlaylistDetailActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/playlist/detail/AudioContentPlaylistDetailActivity.kt index 21674f11..ea7c5c0e 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/audio_content/playlist/detail/AudioContentPlaylistDetailActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/audio_content/playlist/detail/AudioContentPlaylistDetailActivity.kt @@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.audio_content.playlist.detail import android.annotation.SuppressLint import android.content.ComponentName import android.content.Intent -import android.content.SharedPreferences import android.graphics.Rect import android.os.Bundle import android.os.Handler @@ -13,6 +12,9 @@ import android.widget.ImageView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts 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.MediaMetadata import androidx.media3.common.Player @@ -23,6 +25,8 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import coil.load 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.audio_content.AudioContentPlayService 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.extensions.dpToPx import org.koin.android.ext.android.inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import kotlin.random.Random @UnstableApi @@ -60,25 +67,10 @@ class AudioContentPlaylistDetailActivity : BaseActivity() private var mediaController: MediaController? = null + private var mediaControllerFuture: ListenableFuture? = null private val handler = Handler(Looper.getMainLooper()) - - @SuppressLint("SetTextI18n") - 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 val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() } + private var playerStateJob: Job? = null private fun initAndVisibleMiniPlayer() { binding.clMiniPlayer.visibility = View.VISIBLE @@ -94,32 +86,48 @@ class AudioContentPlaylistDetailActivity : BaseActivity + if (isRunning) { + handler.removeCallbacks(showMiniPlayerRunnable) + handler.postDelayed(showMiniPlayerRunnable, 1500) + } else { + deInitMiniPlayer() + } + } + } } } override fun onDestroy() { deInitMiniPlayer() - SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + playerStateJob?.cancel() super.onDestroy() } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatBackgroundPickerDialogFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatBackgroundPickerDialogFragment.kt index e98d78bc..cbaa3d56 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatBackgroundPickerDialogFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatBackgroundPickerDialogFragment.kt @@ -1,12 +1,10 @@ package kr.co.vividnext.sodalive.chat.talk.room import android.annotation.SuppressLint -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.content.edit import androidx.fragment.app.DialogFragment import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -41,7 +39,6 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { private var characterId: Long = 0L private var profileUrl: String = "" - private val prefsName = "chat_room_prefs" private fun bgImageIdKey(roomId: Long) = "chat_bg_image_id_room_$roomId" private lateinit var adapter: BgAdapter @@ -87,8 +84,7 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { private fun loadData() { // 초기 선택: 저장된 이미지 ID 사용 - val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) - val savedId = prefs.getLong(bgImageIdKey(roomId), -1L) + val savedId = ChatRoomPreferenceManager.getLong(bgImageIdKey(roomId), -1L) selectedId = if (savedId > 0) savedId else null items.clear() @@ -124,12 +120,10 @@ class ChatBackgroundPickerDialogFragment : DialogFragment() { } private fun saveAndApply(item: BgItem) { - val prefs = requireActivity().getSharedPreferences(prefsName, Context.MODE_PRIVATE) - prefs.edit { - if (item.id > 0) putLong( - bgImageIdKey(roomId), - item.id - ) else remove(bgImageIdKey(roomId)) + if (item.id > 0) { + ChatRoomPreferenceManager.putLong(bgImageIdKey(roomId), item.id) + } else { + ChatRoomPreferenceManager.remove(bgImageIdKey(roomId)) } (activity as? ChatRoomActivity)?.setChatBackground(item.url) } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt index 89ffbbfd..ba440bd1 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomActivity.kt @@ -9,7 +9,6 @@ import android.text.TextWatcher import android.view.View import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager -import androidx.core.content.edit import androidx.core.view.isVisible import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -55,12 +54,10 @@ class ChatRoomActivity : BaseActivity( // 5.3 헤더 데이터 (7.x 연동 전까지는 nullable 보관) 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 isNoticeHidden(): Boolean = prefs.getBoolean(noticePrefKey(roomId), false) + private fun isNoticeHidden(): Boolean = ChatRoomPreferenceManager.getBoolean(noticePrefKey(roomId), false) private fun setNoticeHidden(hidden: Boolean) { - prefs.edit { putBoolean(noticePrefKey(roomId), hidden) } + ChatRoomPreferenceManager.putBoolean(noticePrefKey(roomId), hidden) } override fun setupView() { @@ -866,7 +863,7 @@ class ChatRoomActivity : BaseActivity( } 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.viewCharacterDim.isVisible = visible } @@ -885,7 +882,7 @@ class ChatRoomActivity : BaseActivity( private fun bgImageIdPrefKey(): String = "chat_bg_image_id_room_$roomId" private fun getSavedBackgroundImageId(): Long? { - val id = prefs.getLong(bgImageIdPrefKey(), -1L) + val id = ChatRoomPreferenceManager.getLong(bgImageIdPrefKey(), -1L) return if (id > 0) id else null } @@ -912,9 +909,7 @@ class ChatRoomActivity : BaseActivity( "chat_bg_image_id_room_$roomId", noticePrefKey(roomId) ) - prefs.edit { - keys.forEach { remove(it) } - } + ChatRoomPreferenceManager.removeAll(keys) } fun onResetChatRequested() { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomMoreDialogFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomMoreDialogFragment.kt index ff96e188..0965da7c 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomMoreDialogFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomMoreDialogFragment.kt @@ -1,6 +1,5 @@ package kr.co.vividnext.sodalive.chat.talk.room -import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -11,7 +10,6 @@ import android.widget.RelativeLayout import androidx.fragment.app.DialogFragment import com.google.android.material.switchmaterial.SwitchMaterial import kr.co.vividnext.sodalive.R -import androidx.core.content.edit /** * 채팅방 우측 상단 더보기 버튼 클릭 시 표시되는 전체화면 다이얼로그. @@ -42,13 +40,12 @@ class ChatRoomMoreDialogFragment : DialogFragment() { // 닫기 버튼 view.findViewById(R.id.iv_close)?.setOnClickListener { dismiss() } - val prefs = requireActivity().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val bgKey = bgPrefKey(roomId) val switch = view.findViewById(R.id.sw_background) - switch?.isChecked = prefs.getBoolean(bgKey, true) + switch?.isChecked = ChatRoomPreferenceManager.getBoolean(bgKey, true) switch?.setOnCheckedChangeListener { _, isChecked -> - prefs.edit { putBoolean(bgKey, isChecked) } + ChatRoomPreferenceManager.putBoolean(bgKey, isChecked) (activity as? ChatRoomActivity)?.applyBackgroundVisibility() } @@ -76,7 +73,6 @@ class ChatRoomMoreDialogFragment : DialogFragment() { companion object { 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" fun newInstance(roomId: Long): ChatRoomMoreDialogFragment { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomPreferenceManager.kt b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomPreferenceManager.kt new file mode 100644 index 00000000..bd7c8433 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/chat/talk/room/ChatRoomPreferenceManager.kt @@ -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 + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val preferenceState = MutableStateFlow>(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) { + 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." } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/AppPreferencesDataStoreProvider.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/AppPreferencesDataStoreProvider.kt new file mode 100644 index 00000000..d50ef310 --- /dev/null +++ b/app/src/main/java/kr/co/vividnext/sodalive/common/AppPreferencesDataStoreProvider.kt @@ -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? = null + + fun get(context: Context): DataStore { + val existing = dataStore + if (existing != null) { + return existing + } + + return synchronized(this) { + dataStore ?: createDataStore(context.applicationContext).also { dataStore = it } + } + } + + private fun createDataStore(context: Context): DataStore { + 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 + } + } +} diff --git a/app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt b/app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt index 5ee58978..fcfa8301 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/common/SharedPreferenceManager.kt @@ -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 + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val preferenceState = MutableStateFlow>(emptyMap()) + private val initLock = Any() + private var observerJob: Job? = null + + @Volatile + private var initialized = false + + val roleFlow: Flow = + preferenceState.map { state -> + state[Constants.PREF_USER_ROLE] as? String ?: MemberRole.USER.name + }.distinctUntilChanged() + + val isPlayerServiceRunningFlow: Flow = + 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 SharedPreferences.get(key: String, defaultValue: T? = null): T { + private fun 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 get() { - val list = sharedPreferences[Constants.PREF_NO_CHAT_ROOM, ""] + val list = getPreference(Constants.PREF_NO_CHAT_ROOM, "") val gson = Gson() val listType = object : TypeToken>() {}.type val myList = gson.fromJson>(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) } } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt index faee6368..6f537851 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/home/HomeFragment.kt @@ -1,7 +1,6 @@ package kr.co.vividnext.sodalive.home import android.content.Intent -import android.content.SharedPreferences import android.graphics.Rect import android.os.Bundle import android.os.Handler @@ -13,6 +12,9 @@ import android.widget.Toast import androidx.annotation.OptIn import androidx.core.content.ContextCompat 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.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -68,6 +70,8 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch @OptIn(UnstableApi::class) class HomeFragment : BaseFragment(FragmentHomeBinding::inflate) { @@ -95,34 +99,17 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl 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.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) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + SharedPreferenceManager.roleFlow.collect { role -> + renderUploadContentByRole(role) } } } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - SharedPreferenceManager.registerOnSharedPreferenceChangeListener(preferenceChangeListener) setupView() bindData() @@ -130,26 +117,13 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl } override fun onDestroyView() { - SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) super.onDestroyView() } private fun setupView() { loadingDialog = LoadingDialog(requireActivity(), layoutInflater) - if (SharedPreferenceManager.role == MemberRole.CREATOR.name) { - binding.llUploadContent.visibility = View.VISIBLE - binding.llUploadContent.setOnClickListener { - startActivity( - Intent( - requireActivity(), - AudioContentUploadActivity::class.java - ) - ) - } - } else { - binding.llUploadContent.visibility = View.GONE - } + renderUploadContentByRole(SharedPreferenceManager.role) if (SharedPreferenceManager.token.isNotBlank()) { binding.llShortIcon.visibility = View.VISIBLE @@ -199,6 +173,23 @@ class HomeFragment : BaseFragment(FragmentHomeBinding::infl 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() { liveAdapter = HomeLiveAdapter { ensureLoginAndAdultAuth(isAdult = it.isAdult) { diff --git a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt index 70b6b129..44a55c8f 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/live/LiveFragment.kt @@ -3,7 +3,6 @@ package kr.co.vividnext.sodalive.live import android.annotation.SuppressLint import android.app.Activity import android.content.Intent -import android.content.SharedPreferences import android.graphics.Rect import android.os.Bundle import android.os.Handler @@ -15,6 +14,9 @@ import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts 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.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -72,6 +74,8 @@ import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch import kotlin.math.roundToInt @UnstableApi @@ -90,27 +94,6 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl private var message = "" 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?) { super.onCreate(savedInstanceState) @@ -131,7 +114,14 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - SharedPreferenceManager.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + SharedPreferenceManager.roleFlow.collect { role -> + renderMakeLiveByRole(role) + } + } + } setupView() @@ -140,7 +130,6 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl } override fun onDestroyView() { - SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) super.onDestroyView() } @@ -159,17 +148,7 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl } } - binding.llMakeLive.visibility = - if (SharedPreferenceManager.role == MemberRole.CREATOR.name) { - View.VISIBLE - } else { - View.GONE - } - - binding.llMakeLive.setOnClickListener { - val intent = Intent(requireContext(), LiveRoomCreateActivity::class.java) - activityResultLauncher.launch(intent) - } + renderMakeLiveByRole(SharedPreferenceManager.role) setupToolbar() setupLiveNow() @@ -181,6 +160,19 @@ class LiveFragment : BaseFragment(FragmentLiveBinding::infl 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() { if (SharedPreferenceManager.token.isNotBlank()) { binding.llShortIcon.visibility = View.VISIBLE diff --git a/app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt b/app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt index 3b192c32..b624f01b 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/main/MainActivity.kt @@ -7,7 +7,6 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences import android.content.res.ColorStateList import android.os.Build import android.os.Bundle @@ -16,6 +15,9 @@ import android.os.Looper import android.view.View import androidx.core.content.ContextCompat 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.MediaMetadata import androidx.media3.common.Player @@ -24,6 +26,7 @@ import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import coil.load import coil.transform.RoundedCornersTransformation +import com.google.common.util.concurrent.ListenableFuture import com.google.firebase.messaging.FirebaseMessaging import com.gun0912.tedpermission.PermissionListener 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 org.koin.android.ext.android.inject import java.util.Locale +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch @UnstableApi class MainActivity : BaseActivity(ActivityMainBinding::inflate) { @@ -63,25 +69,11 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl private lateinit var notificationSettingsDialog: NotificationSettingsDialog private var mediaController: MediaController? = null + private var mediaControllerFuture: ListenableFuture? = null private val handler = Handler(Looper.getMainLooper()) + private val showMiniPlayerRunnable = Runnable { initAndVisibleMiniPlayer() } private val audioContentReceiver = AudioContentReceiver() - - 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 var playerStateJob: Job? = null private fun initAndVisibleMiniPlayer() { binding.clMiniPlayer.visibility = View.VISIBLE @@ -97,32 +89,48 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl } private fun connectPlayerService() { + if (mediaController != null || mediaControllerFuture != null) { + return + } + val componentName = ComponentName(applicationContext, AudioContentPlayerService::class.java) val sessionToken = SessionToken(applicationContext, componentName) - val mediaControllerFuture = + val controllerFuture = MediaController.Builder(applicationContext, sessionToken).buildAsync() - mediaControllerFuture.addListener( + mediaControllerFuture = controllerFuture + controllerFuture.addListener( { - mediaController = mediaControllerFuture.get() - setupMediaController() - updateMediaMetadata(mediaController?.mediaMetadata) - - binding.ivPlayerPlayOrPause.setImageResource( - if (mediaController!!.isPlaying) { - R.drawable.ic_player_pause - } else { - R.drawable.ic_player_play + try { + if (mediaController != null) { + controllerFuture.get().release() + return@addListener } - ) - binding.ivPlayerPlayOrPause.setOnClickListener { - mediaController?.let { - if (it.playWhenReady) { - it.pause() + mediaController = controllerFuture.get() + setupMediaController() + updateMediaMetadata(mediaController?.mediaMetadata) + + binding.ivPlayerPlayOrPause.setImageResource( + if (mediaController?.isPlaying == true) { + R.drawable.ic_player_pause } 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) @@ -166,7 +174,10 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl } private fun deInitMiniPlayer() { + handler.removeCallbacks(showMiniPlayerRunnable) binding.clMiniPlayer.visibility = View.GONE + mediaControllerFuture?.cancel(true) + mediaControllerFuture = null mediaController?.release() mediaController = null } @@ -202,13 +213,17 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl updatePidAndGaid() getEventPopup() - SharedPreferenceManager.registerOnSharedPreferenceChangeListener( - preferenceChangeListener - ) - if (SharedPreferenceManager.isPlayerServiceRunning) { - initAndVisibleMiniPlayer() - } else { - deInitMiniPlayer() + playerStateJob = lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + SharedPreferenceManager.isPlayerServiceRunningFlow.collect { isRunning -> + if (isRunning) { + handler.removeCallbacks(showMiniPlayerRunnable) + handler.postDelayed(showMiniPlayerRunnable, 1500) + } else { + deInitMiniPlayer() + } + } + } } handler.postDelayed({ executeDeeplink(intent) }, 1000) @@ -217,7 +232,7 @@ class MainActivity : BaseActivity(ActivityMainBinding::infl override fun onDestroy() { deInitMiniPlayer() - SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + playerStateJob?.cancel() super.onDestroy() } diff --git a/app/src/main/java/kr/co/vividnext/sodalive/settings/language/LanguageManager.kt b/app/src/main/java/kr/co/vividnext/sodalive/settings/language/LanguageManager.kt index 05f0db84..29a9ef5d 100644 --- a/app/src/main/java/kr/co/vividnext/sodalive/settings/language/LanguageManager.kt +++ b/app/src/main/java/kr/co/vividnext/sodalive/settings/language/LanguageManager.kt @@ -1,21 +1,14 @@ package kr.co.vividnext.sodalive.settings.language import android.content.Context -import android.content.SharedPreferences import android.os.Build -import androidx.core.content.edit -import androidx.preference.PreferenceManager +import kr.co.vividnext.sodalive.common.SharedPreferenceManager object LanguageManager { const val LANG_KO = "ko" const val LANG_EN = "en" 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) { LANG_KO, LANG_EN, LANG_JA -> true else -> false @@ -25,8 +18,9 @@ object LanguageManager { * 사용자가 앱 내에서 명시적으로 선택한 언어 코드를 반환한다. 없으면 null. */ fun getUserSelectedLanguageOrNull(context: Context): String? { - val code = prefs(context).getString(PREF_KEY_APP_LANGUAGE, null) - return code?.takeIf { it.isNotBlank() } + SharedPreferenceManager.init(context.applicationContext) + val code = SharedPreferenceManager.appLanguageCode + return code.takeIf { it.isNotBlank() } } /** @@ -64,6 +58,7 @@ object LanguageManager { fun setSelectedLanguage(context: Context, code: String) { 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 } } diff --git a/docs/20260309_DataStore전환및SharedPreferences마이그레이션.md b/docs/20260309_DataStore전환및SharedPreferences마이그레이션.md new file mode 100644 index 00000000..389928fd --- /dev/null +++ b/docs/20260309_DataStore전환및SharedPreferences마이그레이션.md @@ -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)로 동일하게 중단.