diff --git a/data/schemas/com.project200.data.local.UndabangDatabase/2.json b/data/schemas/com.project200.data.local.UndabangDatabase/2.json new file mode 100644 index 00000000..b31fddfa --- /dev/null +++ b/data/schemas/com.project200.data.local.UndabangDatabase/2.json @@ -0,0 +1,395 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "cc3993046ab06f37e7ded4f707201ced", + "entities": [ + { + "tableName": "exercise_count", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `date` TEXT NOT NULL, `count` INTEGER NOT NULL, PRIMARY KEY(`memberId`, `date`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "count", + "columnName": "count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "date" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exercise_list_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `date` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, `title` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `thumbnailUrls` TEXT, PRIMARY KEY(`memberId`, `localId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "personalType", + "columnName": "personalType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrls", + "columnName": "thumbnailUrls", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "localId" + ] + }, + "indices": [ + { + "name": "index_exercise_list_item_memberId_date", + "unique": false, + "columnNames": [ + "memberId", + "date" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_date` ON `${TABLE_NAME}` (`memberId`, `date`)" + }, + { + "name": "index_exercise_list_item_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_list_item_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "exercise_record_detail", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `title` TEXT NOT NULL, `detail` TEXT NOT NULL, `personalType` TEXT NOT NULL, `startedAt` TEXT NOT NULL, `endedAt` TEXT NOT NULL, `location` TEXT NOT NULL, `pictures` TEXT, PRIMARY KEY(`memberId`, `localId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "detail", + "columnName": "detail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "personalType", + "columnName": "personalType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startedAt", + "columnName": "startedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "endedAt", + "columnName": "endedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pictures", + "columnName": "pictures", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "localId" + ] + }, + "indices": [ + { + "name": "index_exercise_record_detail_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercise_record_detail_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "simple_timer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `time` INTEGER NOT NULL, `createRequestId` TEXT NOT NULL, `pendingEditId` TEXT, `editedAt` INTEGER NOT NULL, PRIMARY KEY(`memberId`, `localId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createRequestId", + "columnName": "createRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingEditId", + "columnName": "pendingEditId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "localId" + ] + }, + "indices": [ + { + "name": "index_simple_timer_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_simple_timer_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "custom_timer", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`memberId` TEXT NOT NULL, `localId` TEXT NOT NULL, `serverId` INTEGER, `syncState` TEXT NOT NULL, `name` TEXT NOT NULL, `steps` TEXT NOT NULL, `createRequestId` TEXT NOT NULL, `pendingEditId` TEXT, `editedAt` INTEGER NOT NULL, PRIMARY KEY(`memberId`, `localId`))", + "fields": [ + { + "fieldPath": "memberId", + "columnName": "memberId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localId", + "columnName": "localId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serverId", + "columnName": "serverId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "syncState", + "columnName": "syncState", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "steps", + "columnName": "steps", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createRequestId", + "columnName": "createRequestId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pendingEditId", + "columnName": "pendingEditId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "editedAt", + "columnName": "editedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "memberId", + "localId" + ] + }, + "indices": [ + { + "name": "index_custom_timer_memberId_serverId", + "unique": false, + "columnNames": [ + "memberId", + "serverId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_custom_timer_memberId_serverId` ON `${TABLE_NAME}` (`memberId`, `serverId`)" + } + ], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cc3993046ab06f37e7ded4f707201ced')" + ] + } +} \ No newline at end of file diff --git a/data/src/main/java/com/project200/data/datasource/TimerLocalDataSource.kt b/data/src/main/java/com/project200/data/datasource/TimerLocalDataSource.kt new file mode 100644 index 00000000..87c3c23b --- /dev/null +++ b/data/src/main/java/com/project200/data/datasource/TimerLocalDataSource.kt @@ -0,0 +1,179 @@ +package com.project200.data.datasource + +import com.project200.data.local.PreferenceManager +import com.project200.data.local.dao.TimerDao +import com.project200.data.local.entity.CachedTimerStep +import com.project200.data.local.entity.CustomTimerEntity +import com.project200.data.local.entity.SimpleTimerEntity +import com.project200.data.local.entity.SyncState +import timber.log.Timber +import java.util.UUID +import javax.inject.Inject + +/** + * 타이머를 읽고 씁니다. 기기가 원본이라 서버 응답 없이도 생성과 수정이 끝납니다. + * + * 계정 조건을 여기서 한 번에 겁니다. 호출부가 memberId를 넘기지 않게 해서 조건을 + * 빠뜨린 쿼리가 나올 자리를 없앱니다 + * + * 상태 전이 규칙은 셋입니다. + * 생성 대기 행을 고치면 생성 대기를 유지합니다. 수정 대기로 바꾸면 전송 작업이 + * 서버에 없는 항목에 수정 요청을 보냅니다 + * 생성 대기 행을 지우면 서버가 모르는 행이라 즉시 지웁니다 + * 서버에 있는 행을 지우면 삭제 대기로 표시만 합니다. 바로 지우면 서버에 알릴 방법이 없습니다 + */ +class TimerLocalDataSource + @Inject + constructor( + private val timerDao: TimerDao, + private val preferenceManager: PreferenceManager, + ) { + suspend fun getSimpleTimers(): List { + val memberId = currentMemberId() ?: return emptyList() + return timerDao.getSimpleTimers(memberId) + } + + suspend fun countSimpleTimers(): Int { + val memberId = currentMemberId() ?: return 0 + return timerDao.countSimpleTimers(memberId) + } + + suspend fun createSimpleTimer(time: Int): String? { + val memberId = currentMemberId() ?: return null + val localId = newId() + timerDao.upsertSimpleTimer( + SimpleTimerEntity( + memberId = memberId, + localId = localId, + serverId = null, + syncState = SyncState.CREATE_PENDING, + time = time, + createRequestId = newId(), + pendingEditId = null, + editedAt = now(), + ), + ) + return localId + } + + suspend fun updateSimpleTimer( + localId: String, + time: Int, + ) { + val memberId = currentMemberId() ?: return + val current = timerDao.getSimpleTimer(memberId, localId) ?: return + timerDao.upsertSimpleTimer( + current.copy( + time = time, + syncState = nextStateOnEdit(current.syncState), + pendingEditId = newId(), + editedAt = now(), + ), + ) + } + + suspend fun deleteSimpleTimer(localId: String) { + val memberId = currentMemberId() ?: return + val current = timerDao.getSimpleTimer(memberId, localId) ?: return + if (current.serverId == null) { + timerDao.deleteSimpleTimer(memberId, localId) + return + } + timerDao.upsertSimpleTimer( + current.copy( + syncState = SyncState.DELETE_PENDING, + pendingEditId = newId(), + editedAt = now(), + ), + ) + } + + suspend fun getCustomTimers(): List { + val memberId = currentMemberId() ?: return emptyList() + return timerDao.getCustomTimers(memberId) + } + + suspend fun getCustomTimer(localId: String): CustomTimerEntity? { + val memberId = currentMemberId() ?: return null + return timerDao.getCustomTimer(memberId, localId) + } + + suspend fun createCustomTimer( + name: String, + steps: List, + ): String? { + val memberId = currentMemberId() ?: return null + val localId = newId() + timerDao.upsertCustomTimer( + CustomTimerEntity( + memberId = memberId, + localId = localId, + serverId = null, + syncState = SyncState.CREATE_PENDING, + name = name, + steps = steps, + createRequestId = newId(), + pendingEditId = null, + editedAt = now(), + ), + ) + return localId + } + + suspend fun updateCustomTimer( + localId: String, + name: String, + steps: List, + ) { + val memberId = currentMemberId() ?: return + val current = timerDao.getCustomTimer(memberId, localId) ?: return + timerDao.upsertCustomTimer( + current.copy( + name = name, + steps = steps, + syncState = nextStateOnEdit(current.syncState), + pendingEditId = newId(), + editedAt = now(), + ), + ) + } + + suspend fun deleteCustomTimer(localId: String) { + val memberId = currentMemberId() ?: return + val current = timerDao.getCustomTimer(memberId, localId) ?: return + if (current.serverId == null) { + timerDao.deleteCustomTimer(memberId, localId) + return + } + timerDao.upsertCustomTimer( + current.copy( + syncState = SyncState.DELETE_PENDING, + pendingEditId = newId(), + editedAt = now(), + ), + ) + } + + suspend fun getPendingSimpleTimers(): List { + val memberId = currentMemberId() ?: return emptyList() + return timerDao.getPendingSimpleTimers(memberId) + } + + suspend fun getPendingCustomTimers(): List { + val memberId = currentMemberId() ?: return emptyList() + return timerDao.getPendingCustomTimers(memberId) + } + + private fun nextStateOnEdit(current: SyncState): SyncState = + if (current == SyncState.CREATE_PENDING) SyncState.CREATE_PENDING else SyncState.UPDATE_PENDING + + private fun newId(): String = UUID.randomUUID().toString() + + private fun now(): Long = System.currentTimeMillis() + + private fun currentMemberId(): String? { + return preferenceManager.getMemberId().also { + if (it == null) Timber.w("회원ID가 없어 타이머 저장을 건너뜁니다") + } + } + } diff --git a/data/src/main/java/com/project200/data/di/DatabaseModule.kt b/data/src/main/java/com/project200/data/di/DatabaseModule.kt index 44739e85..8c80dce7 100644 --- a/data/src/main/java/com/project200/data/di/DatabaseModule.kt +++ b/data/src/main/java/com/project200/data/di/DatabaseModule.kt @@ -7,6 +7,7 @@ import com.project200.data.local.UndabangDatabase import com.project200.data.local.UndabangTypeConverters import com.project200.data.local.dao.ExerciseCountDao import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.data.local.dao.TimerDao import com.project200.domain.manager.SessionDataCleaner import com.squareup.moshi.Moshi import dagger.Binds @@ -67,5 +68,11 @@ abstract class DatabaseModule { fun provideExerciseRecordDao(database: UndabangDatabase): ExerciseRecordDao { return database.exerciseRecordDao() } + + @Provides + @Singleton + fun provideTimerDao(database: UndabangDatabase): TimerDao { + return database.timerDao() + } } } diff --git a/data/src/main/java/com/project200/data/impl/TimerRepositoryImpl.kt b/data/src/main/java/com/project200/data/impl/TimerRepositoryImpl.kt index 446a8775..55269fb5 100644 --- a/data/src/main/java/com/project200/data/impl/TimerRepositoryImpl.kt +++ b/data/src/main/java/com/project200/data/impl/TimerRepositoryImpl.kt @@ -51,7 +51,10 @@ class TimerRepositoryImpl return apiCallBuilder( ioDispatcher = ioDispatcher, apiCall = { apiService.postSimpleTimer(SimpleTimerRequest(time)) }, - mapper = { dto: SimpleTimerIdDTO? -> dto?.simpleTimerId ?: -1L }, + // 서버 ID 없이 성공으로 올리면 로컬이 원본이 될 때 복구 못 하는 행이 생깁니다 + mapper = { dto: SimpleTimerIdDTO? -> + dto?.simpleTimerId ?: throw IllegalStateException("simpleTimerId가 없습니다") + }, ) } @@ -80,7 +83,7 @@ class TimerRepositoryImpl ioDispatcher = ioDispatcher, apiCall = { apiService.getCustomTimer(customTimerId) }, mapper = { dto: GetCustomTimerDetailDTO? -> - dto?.toModel() ?: CustomTimer(-1L, "") + (dto ?: throw IllegalStateException("커스텀 타이머 상세가 없습니다")).toModel() }, ) } diff --git a/data/src/main/java/com/project200/data/local/UndabangDatabase.kt b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt index c701526f..5a81bf25 100644 --- a/data/src/main/java/com/project200/data/local/UndabangDatabase.kt +++ b/data/src/main/java/com/project200/data/local/UndabangDatabase.kt @@ -5,9 +5,12 @@ import androidx.room.RoomDatabase import androidx.room.TypeConverters import com.project200.data.local.dao.ExerciseCountDao import com.project200.data.local.dao.ExerciseRecordDao +import com.project200.data.local.dao.TimerDao +import com.project200.data.local.entity.CustomTimerEntity import com.project200.data.local.entity.ExerciseCountEntity import com.project200.data.local.entity.ExerciseListItemEntity import com.project200.data.local.entity.ExerciseRecordDetailEntity +import com.project200.data.local.entity.SimpleTimerEntity /** * 오프라인 캐시용 로컬 데이터베이스입니다. @@ -20,8 +23,10 @@ import com.project200.data.local.entity.ExerciseRecordDetailEntity ExerciseCountEntity::class, ExerciseListItemEntity::class, ExerciseRecordDetailEntity::class, + SimpleTimerEntity::class, + CustomTimerEntity::class, ], - version = 1, + version = 2, exportSchema = true, ) @TypeConverters(UndabangTypeConverters::class) @@ -30,6 +35,8 @@ abstract class UndabangDatabase : RoomDatabase() { abstract fun exerciseRecordDao(): ExerciseRecordDao + abstract fun timerDao(): TimerDao + companion object { const val DATABASE_NAME = "undabang.db" } diff --git a/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt b/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt index 56c32b90..6ba213a1 100644 --- a/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt +++ b/data/src/main/java/com/project200/data/local/UndabangTypeConverters.kt @@ -3,6 +3,7 @@ package com.project200.data.local import androidx.room.ProvidedTypeConverter import androidx.room.TypeConverter import com.project200.data.local.entity.CachedPicture +import com.project200.data.local.entity.CachedTimerStep import com.project200.data.local.entity.SyncState import com.squareup.moshi.Moshi import com.squareup.moshi.Types @@ -30,6 +31,11 @@ class UndabangTypeConverters(moshi: Moshi) { Types.newParameterizedType(List::class.java, CachedPicture::class.java), ) + private val timerStepListAdapter = + moshi.adapter>( + Types.newParameterizedType(List::class.java, CachedTimerStep::class.java), + ) + @TypeConverter fun fromSyncState(value: SyncState?): String? = value?.name @@ -59,4 +65,10 @@ class UndabangTypeConverters(moshi: Moshi) { @TypeConverter fun toPictureList(value: String?): List? = value?.let(pictureListAdapter::fromJson) + + @TypeConverter + fun fromTimerStepList(value: List?): String? = value?.let(timerStepListAdapter::toJson) + + @TypeConverter + fun toTimerStepList(value: String?): List? = value?.let(timerStepListAdapter::fromJson) } diff --git a/data/src/main/java/com/project200/data/local/dao/TimerDao.kt b/data/src/main/java/com/project200/data/local/dao/TimerDao.kt new file mode 100644 index 00000000..29d3f900 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/dao/TimerDao.kt @@ -0,0 +1,93 @@ +package com.project200.data.local.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import com.project200.data.local.entity.CustomTimerEntity +import com.project200.data.local.entity.SimpleTimerEntity +import com.project200.data.local.entity.SyncState + +@Dao +interface TimerDao { + /** 삭제 대기 행은 화면에서 이미 사라진 타이머라 조회에서 뺍니다 */ + @Query( + "SELECT * FROM simple_timer " + + "WHERE memberId = :memberId AND syncState != :excluded " + + "ORDER BY time", + ) + suspend fun getSimpleTimers( + memberId: String, + excluded: SyncState = SyncState.DELETE_PENDING, + ): List + + /** 최대 6개 제한을 로컬에서 판정할 때 씁니다 */ + @Query( + "SELECT COUNT(*) FROM simple_timer " + + "WHERE memberId = :memberId AND syncState != :excluded", + ) + suspend fun countSimpleTimers( + memberId: String, + excluded: SyncState = SyncState.DELETE_PENDING, + ): Int + + @Query("SELECT * FROM simple_timer WHERE memberId = :memberId AND localId = :localId") + suspend fun getSimpleTimer( + memberId: String, + localId: String, + ): SimpleTimerEntity? + + @Upsert + suspend fun upsertSimpleTimer(timer: SimpleTimerEntity) + + @Query("DELETE FROM simple_timer WHERE memberId = :memberId AND localId = :localId") + suspend fun deleteSimpleTimer( + memberId: String, + localId: String, + ) + + @Query( + "SELECT * FROM custom_timer " + + "WHERE memberId = :memberId AND syncState != :excluded " + + "ORDER BY editedAt", + ) + suspend fun getCustomTimers( + memberId: String, + excluded: SyncState = SyncState.DELETE_PENDING, + ): List + + @Query("SELECT * FROM custom_timer WHERE memberId = :memberId AND localId = :localId") + suspend fun getCustomTimer( + memberId: String, + localId: String, + ): CustomTimerEntity? + + @Upsert + suspend fun upsertCustomTimer(timer: CustomTimerEntity) + + @Query("DELETE FROM custom_timer WHERE memberId = :memberId AND localId = :localId") + suspend fun deleteCustomTimer( + memberId: String, + localId: String, + ) + + /** 전송할 대기 행입니다. 워커가 씁니다 */ + @Query( + "SELECT * FROM simple_timer " + + "WHERE memberId = :memberId AND syncState != :synced " + + "ORDER BY editedAt", + ) + suspend fun getPendingSimpleTimers( + memberId: String, + synced: SyncState = SyncState.SYNCED, + ): List + + @Query( + "SELECT * FROM custom_timer " + + "WHERE memberId = :memberId AND syncState != :synced " + + "ORDER BY editedAt", + ) + suspend fun getPendingCustomTimers( + memberId: String, + synced: SyncState = SyncState.SYNCED, + ): List +} diff --git a/data/src/main/java/com/project200/data/local/entity/CachedTimerStep.kt b/data/src/main/java/com/project200/data/local/entity/CachedTimerStep.kt new file mode 100644 index 00000000..ed1e2364 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/CachedTimerStep.kt @@ -0,0 +1,16 @@ +package com.project200.data.local.entity + +import com.squareup.moshi.JsonClass + +/** + * 커스텀 타이머의 스텝 한 줄입니다. + * + * 서버 스텝 ID를 담지 않습니다. 서버 수정이 전체 교체(PUT)라서 스텝 ID가 왕복에서 + * 유지된다는 보장이 없습니다 + */ +@JsonClass(generateAdapter = true) +data class CachedTimerStep( + val order: Int, + val name: String, + val time: Int, +) diff --git a/data/src/main/java/com/project200/data/local/entity/CustomTimerEntity.kt b/data/src/main/java/com/project200/data/local/entity/CustomTimerEntity.kt new file mode 100644 index 00000000..44e51cd3 --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/CustomTimerEntity.kt @@ -0,0 +1,30 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import androidx.room.Index + +/** + * 커스텀 타이머입니다. 기기가 원본입니다. + * + * 이름과 스텝 목록을 묶어 타이머 하나가 판정 단위입니다. 스텝만 따로 조회할 일이 없고 + * 서버 수정도 전체 교체라서 스텝을 JSON 컬럼에 담습니다 + * + * 목록 정렬은 editedAt으로 합니다. 서버 응답 순서를 그대로 보존해야 하면 + * 서버 목록 반영을 붙이는 이슈에서 순서 컬럼을 추가합니다 + */ +@Entity( + tableName = "custom_timer", + primaryKeys = ["memberId", "localId"], + indices = [Index(value = ["memberId", "serverId"])], +) +data class CustomTimerEntity( + val memberId: String, + val localId: String, + val serverId: Long?, + val syncState: SyncState, + val name: String, + val steps: List, + val createRequestId: String, + val pendingEditId: String?, + val editedAt: Long, +) diff --git a/data/src/main/java/com/project200/data/local/entity/SimpleTimerEntity.kt b/data/src/main/java/com/project200/data/local/entity/SimpleTimerEntity.kt new file mode 100644 index 00000000..aa91e33c --- /dev/null +++ b/data/src/main/java/com/project200/data/local/entity/SimpleTimerEntity.kt @@ -0,0 +1,32 @@ +package com.project200.data.local.entity + +import androidx.room.Entity +import androidx.room.Index + +/** + * 심플 타이머입니다. 기기가 원본입니다. + * + * 행을 식별하는 값은 localId입니다. 오프라인에서 만든 타이머는 서버 ID를 받기 전이라 + * serverId가 비어 있습니다 + * + * 목록 정렬은 화면이 시간 오름차순과 내림차순을 토글하므로 여기서는 정하지 않습니다 + * + * @property createRequestId 생성 요청 식별자. 재시도에도 같은 값을 보내 중복 생성을 막습니다 + * @property pendingEditId 수정과 삭제 요청 식별자. 저장할 때마다 새로 발급합니다 + * @property editedAt 기기가 저장 시점에 찍은 편집 시각(epoch millis). 충돌 판정 기준입니다 + */ +@Entity( + tableName = "simple_timer", + primaryKeys = ["memberId", "localId"], + indices = [Index(value = ["memberId", "serverId"])], +) +data class SimpleTimerEntity( + val memberId: String, + val localId: String, + val serverId: Long?, + val syncState: SyncState, + val time: Int, + val createRequestId: String, + val pendingEditId: String?, + val editedAt: Long, +) diff --git a/data/src/test/java/com/project200/data/datasource/TimerLocalDataSourceTest.kt b/data/src/test/java/com/project200/data/datasource/TimerLocalDataSourceTest.kt new file mode 100644 index 00000000..983328f2 --- /dev/null +++ b/data/src/test/java/com/project200/data/datasource/TimerLocalDataSourceTest.kt @@ -0,0 +1,238 @@ +package com.project200.data.datasource + +import com.google.common.truth.Truth.assertThat +import com.project200.data.local.PreferenceManager +import com.project200.data.local.UndabangDatabase +import com.project200.data.local.createInMemoryDatabase +import com.project200.data.local.entity.CachedTimerStep +import com.project200.data.local.entity.SyncState +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * 타이머 저장층의 상태 전이를 메모리 DB로 검증합니다. + * + * 전이를 틀리면 서버에 없는 타이머에 수정 요청이 나가거나 삭제가 서버에 전달되지 않습니다 + */ +@RunWith(RobolectricTestRunner::class) +class TimerLocalDataSourceTest { + private lateinit var database: UndabangDatabase + private lateinit var preferenceManager: PreferenceManager + private lateinit var dataSource: TimerLocalDataSource + + @Before + fun setUp() { + database = createInMemoryDatabase() + preferenceManager = mockk() + every { preferenceManager.getMemberId() } returns MEMBER_ID + dataSource = TimerLocalDataSource(database.timerDao(), preferenceManager) + } + + @After + fun tearDown() { + database.close() + } + + private val steps = + listOf( + CachedTimerStep(order = 1, name = "준비", time = 30), + CachedTimerStep(order = 2, name = "운동", time = 60), + ) + + private suspend fun markSynced( + localId: String, + serverId: Long, + ) { + val timer = database.timerDao().getSimpleTimer(MEMBER_ID, localId)!! + database.timerDao().upsertSimpleTimer( + timer.copy(serverId = serverId, syncState = SyncState.SYNCED, pendingEditId = null), + ) + } + + // ── 심플 타이머 ──────────────────────────────── + + @Test + fun `생성 - 생성 대기 상태와 요청 식별자를 남긴다`() = + runTest { + // When + val localId = dataSource.createSimpleTimer(time = 60)!! + + // Then + val saved = database.timerDao().getSimpleTimer(MEMBER_ID, localId)!! + assertThat(saved.syncState).isEqualTo(SyncState.CREATE_PENDING) + assertThat(saved.serverId).isNull() + assertThat(saved.createRequestId).isNotEmpty() + assertThat(saved.editedAt).isGreaterThan(0L) + } + + @Test + fun `수정 - 생성 대기 행은 생성 대기를 유지한다`() = + runTest { + // Given: 아직 서버에 없는 타이머 + val localId = dataSource.createSimpleTimer(time = 60)!! + val created = database.timerDao().getSimpleTimer(MEMBER_ID, localId)!! + + // When + dataSource.updateSimpleTimer(localId, time = 90) + + // Then: 수정 대기로 바꾸면 서버에 없는 항목에 수정 요청이 나간다 + val updated = database.timerDao().getSimpleTimer(MEMBER_ID, localId)!! + assertThat(updated.syncState).isEqualTo(SyncState.CREATE_PENDING) + assertThat(updated.time).isEqualTo(90) + assertThat(updated.createRequestId).isEqualTo(created.createRequestId) + assertThat(updated.pendingEditId).isNotNull() + } + + @Test + fun `수정 - 서버에 있는 행은 수정 대기가 된다`() = + runTest { + // Given + val localId = dataSource.createSimpleTimer(time = 60)!! + markSynced(localId, serverId = 7L) + + // When + dataSource.updateSimpleTimer(localId, time = 90) + + // Then + val updated = database.timerDao().getSimpleTimer(MEMBER_ID, localId)!! + assertThat(updated.syncState).isEqualTo(SyncState.UPDATE_PENDING) + assertThat(updated.serverId).isEqualTo(7L) + assertThat(updated.pendingEditId).isNotNull() + } + + @Test + fun `삭제 - 서버에 없는 행은 즉시 지운다`() = + runTest { + // Given + val localId = dataSource.createSimpleTimer(time = 60)!! + + // When + dataSource.deleteSimpleTimer(localId) + + // Then: 서버가 모르는 행이라 삭제를 알릴 필요가 없다 + assertThat(database.timerDao().getSimpleTimer(MEMBER_ID, localId)).isNull() + } + + @Test + fun `삭제 - 서버에 있는 행은 삭제 대기로 남는다`() = + runTest { + // Given + val localId = dataSource.createSimpleTimer(time = 60)!! + markSynced(localId, serverId = 7L) + + // When + dataSource.deleteSimpleTimer(localId) + + // Then + val deleted = database.timerDao().getSimpleTimer(MEMBER_ID, localId)!! + assertThat(deleted.syncState).isEqualTo(SyncState.DELETE_PENDING) + assertThat(dataSource.getSimpleTimers()).isEmpty() + } + + @Test + fun `개수 - 삭제 대기 행은 세지 않는다`() = + runTest { + // Given + dataSource.createSimpleTimer(time = 30) + val toDelete = dataSource.createSimpleTimer(time = 60)!! + markSynced(toDelete, serverId = 7L) + dataSource.deleteSimpleTimer(toDelete) + + // When & Then: 최대 6개 판정에 삭제 대기가 끼면 안 된다 + assertThat(dataSource.countSimpleTimers()).isEqualTo(1) + } + + @Test + fun `목록 - 시간 오름차순으로 나온다`() = + runTest { + // Given + dataSource.createSimpleTimer(time = 90) + dataSource.createSimpleTimer(time = 30) + + // When & Then + assertThat(dataSource.getSimpleTimers().map { it.time }).containsExactly(30, 90).inOrder() + } + + // ── 커스텀 타이머 ──────────────────────────────── + + @Test + fun `커스텀 생성 - 스텝 순서가 유지된다`() = + runTest { + // When + val localId = dataSource.createCustomTimer(name = "8세트", steps = steps)!! + + // Then + val saved = dataSource.getCustomTimer(localId)!! + assertThat(saved.syncState).isEqualTo(SyncState.CREATE_PENDING) + assertThat(saved.steps.map { it.name }).containsExactly("준비", "운동").inOrder() + } + + @Test + fun `커스텀 수정 - 스텝을 통째로 교체한다`() = + runTest { + // Given + val localId = dataSource.createCustomTimer(name = "8세트", steps = steps)!! + + // When + dataSource.updateCustomTimer( + localId, + name = "10세트", + steps = listOf(CachedTimerStep(order = 1, name = "전력", time = 20)), + ) + + // Then: 이름과 스텝 목록이 판정 단위라 항목 단위로 병합하지 않는다 + val updated = dataSource.getCustomTimer(localId)!! + assertThat(updated.name).isEqualTo("10세트") + assertThat(updated.steps).hasSize(1) + } + + @Test + fun `대기 행 조회 - 동기화 완료 행은 빠진다`() = + runTest { + // Given + val pending = dataSource.createSimpleTimer(time = 30)!! + val synced = dataSource.createSimpleTimer(time = 60)!! + markSynced(synced, serverId = 7L) + + // When & Then + assertThat(dataSource.getPendingSimpleTimers().map { it.localId }).containsExactly(pending) + } + + // ── 계정 경계 ──────────────────────────────── + + @Test + fun `회원ID가 없으면 쓰지 않고 읽으면 빈 값이다`() = + runTest { + // Given + every { preferenceManager.getMemberId() } returns null + + // When + val localId = dataSource.createSimpleTimer(time = 60) + + // Then + assertThat(localId).isNull() + assertThat(dataSource.getSimpleTimers()).isEmpty() + assertThat(dataSource.countSimpleTimers()).isEqualTo(0) + } + + @Test + fun `다른 계정의 타이머는 보이지 않는다`() = + runTest { + // Given + dataSource.createSimpleTimer(time = 60) + every { preferenceManager.getMemberId() } returns "member-b" + + // When & Then + assertThat(dataSource.getSimpleTimers()).isEmpty() + } + + companion object { + private const val MEMBER_ID = "member-a" + } +} diff --git a/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt b/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt index cb56e0c1..d460614e 100644 --- a/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt +++ b/data/src/test/java/com/project200/data/di/SchemaRecreatePolicyTest.kt @@ -34,6 +34,7 @@ class SchemaRecreatePolicyTest { runTest { // Given: 캐시가 쌓인 DB var database = openDatabase() + val codeVersion = database.openHelper.readableDatabase.version database.exerciseCountDao().upsertAll( listOf(ExerciseCountEntity(memberId = "member-a", date = date, count = 2)), ) @@ -44,10 +45,10 @@ class SchemaRecreatePolicyTest { val path = context.getDatabasePath(UndabangDatabase.DATABASE_NAME).absolutePath val mismatched = SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READWRITE).use { raw -> - raw.version = 2 + raw.version = codeVersion + 1 raw.version } - assertThat(mismatched).isEqualTo(2) + assertThat(mismatched).isEqualTo(codeVersion + 1) database = openDatabase() // Then: 예외 없이 열리고 캐시는 비어 있다