Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import androidx.lifecycle.repeatOnLifecycle
import com.into.websoso.BuildConfig
import com.into.websoso.core.common.ui.custom.WebsosoCustomSnackBar
import com.into.websoso.core.common.ui.custom.WebsosoCustomToast
import com.into.websoso.core.datastore.migration.VersionedPreferencesMigration
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import java.io.Serializable
Expand Down Expand Up @@ -142,12 +143,18 @@ inline fun <reified T : Parcelable> Bundle.getAdaptedParcelable(key: String): T?
as? T
}

fun Context.createDataStore(preferencesName: String): DataStore<Preferences> =
fun Context.createDataStore(
preferencesName: String,
schemaVersion: Int,
): DataStore<Preferences> =
PreferenceDataStoreFactory.create(
corruptionHandler = ReplaceFileCorruptionHandler(
produceNewData = { emptyPreferences() },
),
migrations = listOf(SharedPreferencesMigration(this, preferencesName)),
migrations = listOf(
SharedPreferencesMigration(this, preferencesName),
VersionedPreferencesMigration(currentVersion = schemaVersion),
),
produceFile = { this.preferencesDataStoreFile(preferencesName) },
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ object DataStoreModule {
@Provides
fun provideUserPreferencesDataStore(
@ApplicationContext context: Context,
): DataStore<Preferences> = context.createDataStore(USER_PREFERENCES)
): DataStore<Preferences> =
context.createDataStore(
preferencesName = USER_PREFERENCES,
schemaVersion = USER_PREFERENCES_SCHEMA_VERSION,
)

private const val USER_PREFERENCES = "com.teamwss.websoso.user_preferences"
private const val USER_PREFERENCES_SCHEMA_VERSION = 1
}
1 change: 1 addition & 0 deletions core/datastore/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ dependencies {
implementation(libs.serialization.json)

testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,14 @@ package com.into.websoso.core.datastore.datasource.library
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.into.websoso.core.common.dispatchers.Dispatcher
import com.into.websoso.core.common.dispatchers.WebsosoDispatchers
import com.into.websoso.core.datastore.datasource.library.mapper.toData
import com.into.websoso.core.datastore.datasource.library.mapper.toPreferences
import com.into.websoso.core.datastore.datasource.library.migration.LIBRARY_FILTER_PARAMS_KEY
import com.into.websoso.core.datastore.datasource.library.model.LibraryFilterPreferences
import com.into.websoso.core.datastore.di.LibraryFilterDataStore
import com.into.websoso.data.filter.datasource.LibraryFilterLocalDataSource
import com.into.websoso.data.filter.model.DEFAULT_IS_RATINGLESS
import com.into.websoso.data.filter.model.DEFAULT_RATING_MAX
import com.into.websoso.data.filter.model.DEFAULT_RATING_MIN
import com.into.websoso.data.filter.model.LibraryFilter
import dagger.Binds
import dagger.Module
Expand All @@ -25,9 +22,6 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import javax.inject.Inject
import javax.inject.Singleton

Expand All @@ -44,28 +38,13 @@ internal class DefaultLibraryFilterDataSource
.map { prefs ->
prefs[LIBRARY_FILTER_PARAMS_KEY]?.let { jsonString ->
withContext(dispatcher) {
json
.decodeFromString<LibraryFilterPreferences>(jsonString)
.migrateLegacyRating(jsonString)
.toData()
runCatching { json.decodeFromString<LibraryFilterPreferences>(jsonString) }
.getOrNull()
?.toData()
}
}
}.distinctUntilChanged()

private fun LibraryFilterPreferences.migrateLegacyRating(jsonString: String): LibraryFilterPreferences {
val root = json.parseToJsonElement(jsonString).jsonObject

val hasNewRating = root.containsKey(RATING_MIN_KEY) || root.containsKey(RATING_MAX_KEY)
val legacyRating = root[LEGACY_NOVEL_RATING_KEY]?.jsonPrimitive?.floatOrNull
if (hasNewRating || legacyRating == null) return this

return copy(
ratingMin = legacyRating.coerceIn(DEFAULT_RATING_MIN, DEFAULT_RATING_MAX),
ratingMax = DEFAULT_RATING_MAX,
isRatingless = DEFAULT_IS_RATINGLESS,
)
}

override suspend fun updateLibraryFilter(libraryFilter: LibraryFilter) {
val encodedJsonString = withContext(dispatcher) {
json.encodeToString(
Expand All @@ -83,13 +62,6 @@ internal class DefaultLibraryFilterDataSource
prefs.remove(LIBRARY_FILTER_PARAMS_KEY)
}
}

companion object {
private val LIBRARY_FILTER_PARAMS_KEY = stringPreferencesKey("LIBRARY_FILTER_PARAMS_KEY")
private const val LEGACY_NOVEL_RATING_KEY = "novelRating"
private const val RATING_MIN_KEY = "ratingMin"
private const val RATING_MAX_KEY = "ratingMax"
}
}

@Module
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,8 @@ internal fun LibraryFilter.toPreferences(): LibraryFilterPreferences =
keywords = keywords,
)

// 1.8.x 이하는 정렬 기준을 enum 이름("RECENT", "OLD")으로 저장했다.
// 서버 v2 명세 key로 마이그레이션하고, 알 수 없는 값은 기본값으로 대체한다.
private val LEGACY_SORT_CRITERIA_MIGRATION = mapOf(
"RECENT" to "created_desc",
"OLD" to "created_asc",
)

// 저장값은 여러 버전의 앱이 남긴 값이므로 서버 명세에 없는 정렬 기준은 기본값으로 대체한다.
// 구버전 값 변환은 LibraryFilterMigration에서 처리한다.
private val VALID_SORT_CRITERIA_KEYS = setOf(
"created_desc",
"created_asc",
Expand All @@ -34,14 +29,11 @@ private val VALID_SORT_CRITERIA_KEYS = setOf(
"rating_asc",
)

private fun String.toMigratedSortCriteria(): String =
LEGACY_SORT_CRITERIA_MIGRATION[this]
?: takeIf { it in VALID_SORT_CRITERIA_KEYS }
?: DEFAULT_SORT_CRITERIA
private fun String.toValidSortCriteria(): String = takeIf { it in VALID_SORT_CRITERIA_KEYS } ?: DEFAULT_SORT_CRITERIA

internal fun LibraryFilterPreferences.toData(): LibraryFilter =
LibraryFilter(
sortCriteria = sortCriteria.toMigratedSortCriteria(),
sortCriteria = sortCriteria.toValidSortCriteria(),
isInterested = isInterested,
readStatuses = readStatuses,
attractivePoints = attractivePoints,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.into.websoso.core.datastore.datasource.library.migration

import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import com.into.websoso.core.datastore.migration.VersionedPreferencesMigration
import com.into.websoso.data.filter.model.DEFAULT_IS_RATINGLESS
import com.into.websoso.data.filter.model.DEFAULT_RATING_MAX
import com.into.websoso.data.filter.model.DEFAULT_RATING_MIN
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.floatOrNull
import kotlinx.serialization.json.jsonObject

internal val LIBRARY_FILTER_PARAMS_KEY = stringPreferencesKey("LIBRARY_FILTER_PARAMS_KEY")

private const val LIBRARY_FILTER_SCHEMA_VERSION = 1

internal val libraryFilterMigration = VersionedPreferencesMigration(
currentVersion = LIBRARY_FILTER_SCHEMA_VERSION,
steps = mapOf(
0 to MutablePreferences::migrateLibraryFilterFromLegacy,
),
)

private const val SORT_CRITERIA_KEY = "sortCriteria"
private const val LEGACY_NOVEL_RATING_KEY = "novelRating"
private const val RATING_MIN_KEY = "ratingMin"
private const val RATING_MAX_KEY = "ratingMax"
private const val IS_RATINGLESS_KEY = "isRatingless"

// 1.8.x 이하는 정렬 기준을 enum 이름("RECENT", "OLD")으로 저장했다.
private val LEGACY_SORT_CRITERIA = mapOf(
"RECENT" to "created_desc",
"OLD" to "created_asc",
)

/**
* 0 → 1: 버전 관리 도입 이전 서재 필터를 서버 v2 명세로 변환한다.
* - 정렬 기준 enum 이름을 서버 key로 변환한다.
* - 단일 별점(novelRating)을 별점 범위(ratingMin, ratingMax)로 변환한다.
* - JSON으로 읽을 수 없는 값은 폐기한다.
*/
private fun MutablePreferences.migrateLibraryFilterFromLegacy() {
val jsonString = this[LIBRARY_FILTER_PARAMS_KEY] ?: return
val root = runCatching { Json.parseToJsonElement(jsonString).jsonObject }.getOrNull()
if (root == null) {
remove(LIBRARY_FILTER_PARAMS_KEY)
return
}

this[LIBRARY_FILTER_PARAMS_KEY] = root
.migrateLegacySortCriteria()
.migrateLegacyRating()
.toString()
}

private fun JsonObject.migrateLegacySortCriteria(): JsonObject {
val sortCriteria = (this[SORT_CRITERIA_KEY] as? JsonPrimitive)?.contentOrNull
val migrated = LEGACY_SORT_CRITERIA[sortCriteria] ?: return this

return JsonObject(this + (SORT_CRITERIA_KEY to JsonPrimitive(migrated)))
}

private fun JsonObject.migrateLegacyRating(): JsonObject {
val legacyRating = (this[LEGACY_NOVEL_RATING_KEY] as? JsonPrimitive)?.floatOrNull
val hasNewRating = containsKey(RATING_MIN_KEY) || containsKey(RATING_MAX_KEY)
if (hasNewRating || legacyRating == null) return JsonObject(this - LEGACY_NOVEL_RATING_KEY)

return JsonObject(
this - LEGACY_NOVEL_RATING_KEY +
mapOf(
RATING_MIN_KEY to JsonPrimitive(legacyRating.coerceIn(DEFAULT_RATING_MIN, DEFAULT_RATING_MAX)),
RATING_MAX_KEY to JsonPrimitive(DEFAULT_RATING_MAX),
IS_RATINGLESS_KEY to JsonPrimitive(DEFAULT_IS_RATINGLESS),
),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
import com.into.websoso.core.datastore.datasource.feed.DefaultPendingFeedLikeStore
import com.into.websoso.core.datastore.datasource.library.migration.libraryFilterMigration
import com.into.websoso.core.datastore.migration.VersionedPreferencesMigration
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
Expand All @@ -16,22 +18,31 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class)
internal object DataStoreModule {
private const val ACCOUNT_DATASTORE = "ACCOUNT_DATASTORE"
private val Context.accountDataStore: DataStore<Preferences> by preferencesDataStore(name = ACCOUNT_DATASTORE)
private const val ACCOUNT_SCHEMA_VERSION = 1
private val Context.accountDataStore: DataStore<Preferences> by preferencesDataStore(
name = ACCOUNT_DATASTORE,
produceMigrations = { listOf(VersionedPreferencesMigration(currentVersion = ACCOUNT_SCHEMA_VERSION)) },
)

private const val LIBRARY_FILTER_DATASTORE = "LIBRARY_FILTER_DATASTORE"
private val Context.libraryFilterDataStore: DataStore<Preferences> by preferencesDataStore(
name = LIBRARY_FILTER_DATASTORE,
produceMigrations = { listOf(libraryFilterMigration) },
)

private const val USER_DATASTORE = "USER_DATASTORE"
private const val USER_SCHEMA_VERSION = 1
private val Context.userDataStore: DataStore<Preferences> by preferencesDataStore(
name = USER_DATASTORE,
produceMigrations = { listOf(VersionedPreferencesMigration(currentVersion = USER_SCHEMA_VERSION)) },
)

private const val PENDING_FEED_LIKE_DATASTORE = "PENDING_FEED_LIKE_DATASTORE"
private const val PENDING_FEED_LIKE_SCHEMA_VERSION = 1
private val Context.pendingFeedLikeDataStore: DataStore<Preferences> by preferencesDataStore(
name = PENDING_FEED_LIKE_DATASTORE,
corruptionHandler = DefaultPendingFeedLikeStore.corruptionHandler,
produceMigrations = { listOf(VersionedPreferencesMigration(currentVersion = PENDING_FEED_LIKE_SCHEMA_VERSION)) },
)

@Provides
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package com.into.websoso.core.datastore.migration

import androidx.datastore.core.DataMigration
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey

/**
* 저장소에 스키마 버전을 함께 기록하고, 저장된 버전이 [currentVersion]보다 낮으면 단계별로 변환한다.
*
* 앱은 배포 시 전부 교체되지만 저장된 값은 이전 버전 앱이 남긴 그대로 남는다.
* 저장 명세(키 이름, 값 형식)를 바꿀 때는 [currentVersion]을 올리고 이전 버전에서 넘어오는 단계를 [steps]에 추가한다.
*
* - 버전 키가 없으면 버전 관리 도입 이전([LEGACY_SCHEMA_VERSION])으로 본다.
* - [steps]의 키는 변환 전 버전이며, 해당 단계는 `버전 → 버전 + 1` 변환을 수행한다.
* - 신규 설치에서도 모든 단계가 빈 저장소를 대상으로 실행되므로, 각 단계는 값이 없는 경우를 처리해야 한다.
*/
class VersionedPreferencesMigration(
private val currentVersion: Int,
private val steps: Map<Int, MutablePreferences.() -> Unit> = emptyMap(),
) : DataMigration<Preferences> {
init {
require(currentVersion > LEGACY_SCHEMA_VERSION)
require(steps.keys.all { it in LEGACY_SCHEMA_VERSION until currentVersion })
}

override suspend fun shouldMigrate(currentData: Preferences): Boolean = currentData.schemaVersion < currentVersion

override suspend fun migrate(currentData: Preferences): Preferences =
currentData.toMutablePreferences().apply {
(currentData.schemaVersion until currentVersion).forEach { version ->
steps[version]?.invoke(this)
}
this[SCHEMA_VERSION] = currentVersion
}

override suspend fun cleanUp() = Unit

private val Preferences.schemaVersion: Int
get() = this[SCHEMA_VERSION] ?: LEGACY_SCHEMA_VERSION

companion object {
internal val SCHEMA_VERSION = intPreferencesKey("SCHEMA_VERSION")
internal const val LEGACY_SCHEMA_VERSION = 0
}
}
Loading
Loading