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
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'
}

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 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)

View File

@@ -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<ActivityAudioContentPlay
private val contentList = mutableListOf<AudioContentPlaylistContent>()
private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = 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<ActivityAudioContentPlay
}
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.ivPlayOrPause.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.ivPlayOrPause.setOnClickListener {
mediaController?.let {
if (it.playWhenReady) {
it.pause()
mediaController = controllerFuture.get()
setupMediaController()
updateMediaMetadata(mediaController?.mediaMetadata)
binding.ivPlayOrPause.setImageResource(
if (mediaController?.isPlaying == true) {
R.drawable.ic_player_pause
} 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)
@@ -163,7 +171,10 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
}
private fun deInitMiniPlayer() {
handler.removeCallbacks(showMiniPlayerRunnable)
binding.clMiniPlayer.visibility = View.GONE
mediaControllerFuture?.cancel(true)
mediaControllerFuture = null
mediaController?.release()
mediaController = null
}
@@ -180,18 +191,24 @@ class AudioContentPlaylistDetailActivity : BaseActivity<ActivityAudioContentPlay
bindData()
viewModel.getPlaylistDetail(playlistId)
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()
}
}
}
}
}
override fun onDestroy() {
deInitMiniPlayer()
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
playerStateJob?.cancel()
super.onDestroy()
}

View File

@@ -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)
}

View File

@@ -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<ActivityChatRoomBinding>(
// 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<ActivityChatRoomBinding>(
}
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<ActivityChatRoomBinding>(
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<ActivityChatRoomBinding>(
"chat_bg_image_id_room_$roomId",
noticePrefKey(roomId)
)
prefs.edit {
keys.forEach { remove(it) }
}
ChatRoomPreferenceManager.removeAll(keys)
}
fun onResetChatRequested() {

View File

@@ -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<ImageView>(R.id.iv_close)?.setOnClickListener { dismiss() }
val prefs = requireActivity().getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val bgKey = bgPrefKey(roomId)
val switch = view.findViewById<SwitchMaterial>(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 {

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

View File

@@ -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>(FragmentHomeBinding::inflate) {
@@ -95,34 +99,17 @@ class HomeFragment : BaseFragment<FragmentHomeBinding>(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>(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>(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) {

View File

@@ -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>(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>(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>(FragmentLiveBinding::infl
}
override fun onDestroyView() {
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
super.onDestroyView()
}
@@ -159,17 +148,7 @@ class LiveFragment : BaseFragment<FragmentLiveBinding>(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>(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

View File

@@ -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>(ActivityMainBinding::inflate) {
@@ -63,25 +69,11 @@ class MainActivity : BaseActivity<ActivityMainBinding>(ActivityMainBinding::infl
private lateinit var notificationSettingsDialog: NotificationSettingsDialog
private var mediaController: MediaController? = null
private var mediaControllerFuture: ListenableFuture<MediaController>? = 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>(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>(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>(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>(ActivityMainBinding::infl
override fun onDestroy() {
deInitMiniPlayer()
SharedPreferenceManager.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener)
playerStateJob?.cancel()
super.onDestroy()
}

View File

@@ -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
}
}