diff --git a/.idea/planningMode.xml b/.idea/planningMode.xml new file mode 100644 index 0000000..3298ef1 --- /dev/null +++ b/.idea/planningMode.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/login_v3/data/api/Main_API_Service.kt b/app/src/main/java/com/example/login_v3/data/api/Main_API_Service.kt index ad5cb82..c20c4a6 100644 --- a/app/src/main/java/com/example/login_v3/data/api/Main_API_Service.kt +++ b/app/src/main/java/com/example/login_v3/data/api/Main_API_Service.kt @@ -30,6 +30,10 @@ data class RefreshResponse( interface TecnologiaApi { + //sync dm data + @GET("api/sync") + suspend fun sync(@Query("since") since: Long): Response + @GET("api/health") suspend fun checkHealth(): Response diff --git a/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt b/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt index 8b94e29..bcb31aa 100644 --- a/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt +++ b/app/src/main/java/com/example/login_v3/data/api/api_class/DM_Basic.kt @@ -1,7 +1,7 @@ package com.example.login_v3.data.api.api_class import com.example.login_v3.R -import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus +import com.example.login_v3.data.local.entities.MessageStatus import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.Moshi @@ -162,7 +162,52 @@ data class MessageReactionUsersResponse( -//voice call +// --- Delta-Sync 相關 --- +@JsonClass(generateAdapter = true) +data class SyncResponse( + @Json(name = "blocks") val blocks: List, + @Json(name = "friends") val friends: List, + @Json(name = "full_snapshot") val fullSnapshot: Boolean, + @Json(name = "next_since") val nextSince: Long, + @Json(name = "rooms") val rooms: List +) + +@JsonClass(generateAdapter = true) +data class SyncBlock( + @Json(name = "avatar_url") val avatarUrl: String?, + @Json(name = "blocked_at") val blockedAt: String, + @Json(name = "display_name") val displayName: String, + @Json(name = "removed") val removed: Boolean = false, + @Json(name = "user_id") val userId: String, + @Json(name = "username") val username: String +) + +@JsonClass(generateAdapter = true) +data class SyncFriend( + @Json(name = "avatar_url") val avatarUrl: String?, + @Json(name = "display_name") val displayName: String, + @Json(name = "presence_status") val presenceStatus: String, + @Json(name = "removed") val removed: Boolean = false, + @Json(name = "status") val status: String, + @Json(name = "user_id") val userId: String, + @Json(name = "username") val username: String +) + +@JsonClass(generateAdapter = true) +data class SyncRoom( + @Json(name = "is_hidden") val isHidden: Boolean, + @Json(name = "is_muted") val isMuted: Boolean, + @Json(name = "is_pinned") val isPinned: Boolean, + @Json(name = "last_message") val lastMessage: LastMessage?, + @Json(name = "mention_count") val mentionCount: Int, + @Json(name = "nickname") val nickname: String?, + @Json(name = "partner") val partner: Partner?, + @Json(name = "removed") val removed: Boolean = false, + @Json(name = "room_id") val roomId: String, + @Json(name = "unread_count") val unreadCount: Int +) + +// --- Voice Call 相關 --- @JsonClass(generateAdapter = true) data class CallLogInfo( @Json(name = "type") val type: String, // 例如: call_missed, call_rejected diff --git a/app/src/main/java/com/example/login_v3/data/di/AppDatabase.kt b/app/src/main/java/com/example/login_v3/data/di/AppDatabase.kt index 5fe81ab..22c6b9e 100644 --- a/app/src/main/java/com/example/login_v3/data/di/AppDatabase.kt +++ b/app/src/main/java/com/example/login_v3/data/di/AppDatabase.kt @@ -3,11 +3,29 @@ package com.example.login_v3.data.di import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.example.login_v3.home.Message.ViewModel.Detail.MessageDao -import com.example.login_v3.home.Message.ViewModel.Detail.MessageEntity +import com.example.login_v3.data.local.dao.BlockedUserDao +import com.example.login_v3.data.local.dao.FriendDao +import com.example.login_v3.data.local.dao.MessageDao +import com.example.login_v3.data.local.dao.RoomLocalDao +import com.example.login_v3.data.local.entities.BlockedUserLocalEntity +import com.example.login_v3.data.local.entities.FriendLocalEntity +import com.example.login_v3.data.local.entities.MessageEntity +import com.example.login_v3.data.local.entities.RoomLocalEntity -@Database(entities = [MessageEntity::class], version = 1, exportSchema = false) +@Database( + entities = [ + MessageEntity::class, + FriendLocalEntity::class, + RoomLocalEntity::class, + BlockedUserLocalEntity::class + ], + version = 2, + exportSchema = false +) @TypeConverters(RoomConverters::class) abstract class AppDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao -} \ No newline at end of file + abstract fun friendDao(): FriendDao + abstract fun roomLocalDao(): RoomLocalDao + abstract fun blockedUserDao(): BlockedUserDao +} diff --git a/app/src/main/java/com/example/login_v3/data/di/DatabaseModule.kt b/app/src/main/java/com/example/login_v3/data/di/DatabaseModule.kt index 0e00fbe..e54ba23 100644 --- a/app/src/main/java/com/example/login_v3/data/di/DatabaseModule.kt +++ b/app/src/main/java/com/example/login_v3/data/di/DatabaseModule.kt @@ -2,7 +2,7 @@ package com.example.login_v3.data.di import android.content.Context import androidx.room.Room -import com.example.login_v3.home.Message.ViewModel.Detail.MessageDao +import com.example.login_v3.data.local.dao.MessageDao import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,7 +24,7 @@ object DatabaseModule { AppDatabase::class.java, "tecnologia_chat_db" // 資料庫檔案名稱 ) - // .fallbackToDestructiveMigration() // 💡 提示:未來如果修改 Entity 欄位導致崩潰,可以先取消這行註解來清除舊資料庫 + .fallbackToDestructiveMigration() .build() } diff --git a/app/src/main/java/com/example/login_v3/data/di/TypeConverter.kt b/app/src/main/java/com/example/login_v3/data/di/TypeConverter.kt index a3264d5..68b1b4f 100644 --- a/app/src/main/java/com/example/login_v3/data/di/TypeConverter.kt +++ b/app/src/main/java/com/example/login_v3/data/di/TypeConverter.kt @@ -4,29 +4,62 @@ import androidx.room.TypeConverter import com.squareup.moshi.Moshi import com.squareup.moshi.Types import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory // 👈 確保有 import 這個 +import com.example.login_v3.data.api.api_class.LastMessage +import com.example.login_v3.data.api.api_class.Partner import com.example.login_v3.data.api.api_class.Reaction class RoomConverters { - // 💡 核心修正:加入 KotlinJsonAdapterFactory 賦予 Moshi 反射辨識 Kotlin Data Class 的能力 private val moshi = Moshi.Builder() .addLast(KotlinJsonAdapterFactory()) .build() private val reactionListType = Types.newParameterizedType(List::class.java, Reaction::class.java) - private val adapter = moshi.adapter>(reactionListType) + private val reactionAdapter = moshi.adapter>(reactionListType) + private val partnerAdapter = moshi.adapter(Partner::class.java) + private val lastMessageAdapter = moshi.adapter(LastMessage::class.java) @TypeConverter fun fromReactionList(reactions: List?): String? { - return adapter.toJson(reactions ?: emptyList()) + return reactionAdapter.toJson(reactions ?: emptyList()) } @TypeConverter fun toReactionList(json: String?): List? { if (json.isNullOrBlank()) return emptyList() return try { - adapter.fromJson(json) + reactionAdapter.fromJson(json) } catch (e: Exception) { emptyList() } } -} \ No newline at end of file + + @TypeConverter + fun fromPartner(partner: Partner?): String? { + return partnerAdapter.toJson(partner) + } + + @TypeConverter + fun toPartner(json: String?): Partner? { + if (json.isNullOrBlank()) return null + return try { + partnerAdapter.fromJson(json) + } catch (e: Exception) { + null + } + } + + @TypeConverter + fun fromLastMessage(lastMessage: LastMessage?): String? { + return lastMessageAdapter.toJson(lastMessage) + } + + @TypeConverter + fun toLastMessage(json: String?): LastMessage? { + if (json.isNullOrBlank()) return null + return try { + lastMessageAdapter.fromJson(json) + } catch (e: Exception) { + null + } + } +} diff --git a/app/src/main/java/com/example/login_v3/data/local/UserPreferences/UserPreferences_Repository.kt b/app/src/main/java/com/example/login_v3/data/local/UserPreferences/UserPreferences_Repository.kt index 3507c9a..c082af6 100644 --- a/app/src/main/java/com/example/login_v3/data/local/UserPreferences/UserPreferences_Repository.kt +++ b/app/src/main/java/com/example/login_v3/data/local/UserPreferences/UserPreferences_Repository.kt @@ -21,6 +21,26 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val THEME_MODE = stringPreferencesKey("theme_mode") // 新增:儲存自訂壁紙檔案路徑的 Key val CUSTOM_WALLPAPER_PATH = stringPreferencesKey("custom_wallpaper_path") + val SYNC_SINCE = androidx.datastore.preferences.core.longPreferencesKey("sync_since") + } + + // 讀取同步時間 + val syncSinceFlow: Flow = dataStore.data + .catch { exception -> + if (exception is IOException) { + emit(emptyPreferences()) + } else { + throw exception + } + } + .map { preferences -> + preferences[PreferencesKeys.SYNC_SINCE] ?: 0L + } + + suspend fun saveSyncSince(since: Long) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.SYNC_SINCE] = since + } } // 讀取設定:直接在宣告時賦值,簡潔又安全 diff --git a/app/src/main/java/com/example/login_v3/data/local/dao/MessageDao.kt b/app/src/main/java/com/example/login_v3/data/local/dao/MessageDao.kt new file mode 100644 index 0000000..382a8dc --- /dev/null +++ b/app/src/main/java/com/example/login_v3/data/local/dao/MessageDao.kt @@ -0,0 +1,33 @@ +package com.example.login_v3.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.example.login_v3.data.local.entities.MessageEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface MessageDao { + @Query("SELECT * FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt DESC") + fun getMessagesFlow(roomId: String): Flow> + + @Query("SELECT * FROM messages WHERE id = :messageId LIMIT 1") + suspend fun getMessageById(messageId: String): MessageEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdate(message: MessageEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdateList(messages: List) + + @Query("DELETE FROM messages WHERE id = :id") + suspend fun deleteById(id: String) + + @Transaction + suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) { + deleteById(tempId) + insertOrUpdate(successEntity) + } +} diff --git a/app/src/main/java/com/example/login_v3/data/local/dao/SyncDAOs.kt b/app/src/main/java/com/example/login_v3/data/local/dao/SyncDAOs.kt new file mode 100644 index 0000000..b485334 --- /dev/null +++ b/app/src/main/java/com/example/login_v3/data/local/dao/SyncDAOs.kt @@ -0,0 +1,55 @@ +package com.example.login_v3.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.example.login_v3.data.local.entities.BlockedUserLocalEntity +import com.example.login_v3.data.local.entities.FriendLocalEntity +import com.example.login_v3.data.local.entities.RoomLocalEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface FriendDao { + @Query("SELECT * FROM friends") + fun getAllFriendsFlow(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdate(friends: List) + + @Query("DELETE FROM friends WHERE userId = :userId") + suspend fun deleteById(userId: String) + + @Query("DELETE FROM friends") + suspend fun deleteAll() +} + +@Dao +interface RoomLocalDao { + @Query("SELECT * FROM rooms ORDER BY isPinned DESC") + fun getAllRoomsFlow(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdate(rooms: List) + + @Query("DELETE FROM rooms WHERE roomId = :roomId") + suspend fun deleteById(roomId: String) + + @Query("DELETE FROM rooms") + suspend fun deleteAll() +} + +@Dao +interface BlockedUserDao { + @Query("SELECT * FROM blocks") + fun getAllBlocksFlow(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertOrUpdate(blocks: List) + + @Query("DELETE FROM blocks WHERE userId = :userId") + suspend fun deleteById(userId: String) + + @Query("DELETE FROM blocks") + suspend fun deleteAll() +} diff --git a/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt b/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt new file mode 100644 index 0000000..1086026 --- /dev/null +++ b/app/src/main/java/com/example/login_v3/data/local/entities/MessageEntity.kt @@ -0,0 +1,22 @@ +package com.example.login_v3.data.local.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.example.login_v3.data.api.api_class.Reaction + +enum class MessageStatus { SENDING, SUCCESS, FAILED } + +@Entity(tableName = "messages") +data class MessageEntity( + @PrimaryKey val id: String, + val chatRoomId: String, + val senderId: String, + val content: String, + val type: String, + val createdAt: String, + val isEdited: Boolean, + val isDeleted: Boolean, + val replyToId: String?, + val status: MessageStatus, + val reactions: List? +) diff --git a/app/src/main/java/com/example/login_v3/data/local/entities/SyncEntities.kt b/app/src/main/java/com/example/login_v3/data/local/entities/SyncEntities.kt new file mode 100644 index 0000000..ddd7fa4 --- /dev/null +++ b/app/src/main/java/com/example/login_v3/data/local/entities/SyncEntities.kt @@ -0,0 +1,38 @@ +package com.example.login_v3.data.local.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.example.login_v3.data.api.api_class.LastMessage +import com.example.login_v3.data.api.api_class.Partner + +@Entity(tableName = "friends") +data class FriendLocalEntity( + @PrimaryKey val userId: String, + val username: String, + val displayName: String, + val avatarUrl: String?, + val status: String, + val presenceStatus: String +) + +@Entity(tableName = "rooms") +data class RoomLocalEntity( + @PrimaryKey val roomId: String, + val nickname: String?, + val isHidden: Boolean, + val isMuted: Boolean, + val isPinned: Boolean, + val unreadCount: Int, + val mentionCount: Int, + val partner: Partner?, + val lastMessage: LastMessage? +) + +@Entity(tableName = "blocks") +data class BlockedUserLocalEntity( + @PrimaryKey val userId: String, + val username: String, + val displayName: String, + val avatarUrl: String?, + val blockedAt: String +) diff --git a/app/src/main/java/com/example/login_v3/data/repository/basic/SyncRepository.kt b/app/src/main/java/com/example/login_v3/data/repository/basic/SyncRepository.kt new file mode 100644 index 0000000..cb83022 --- /dev/null +++ b/app/src/main/java/com/example/login_v3/data/repository/basic/SyncRepository.kt @@ -0,0 +1,105 @@ +package com.example.login_v3.data.repository.basic + +import androidx.room.withTransaction +import com.example.login_v3.data.api.TecnologiaApi +import com.example.login_v3.data.di.AppDatabase +import com.example.login_v3.data.local.UserPreferences.UserPreferencesRepository +import com.example.login_v3.data.local.entities.BlockedUserLocalEntity +import com.example.login_v3.data.local.entities.FriendLocalEntity +import com.example.login_v3.data.local.entities.RoomLocalEntity +import kotlinx.coroutines.flow.first +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SyncRepository @Inject constructor( + private val api: TecnologiaApi, + private val db: AppDatabase, + private val userPrefs: UserPreferencesRepository +) { + private val friendDao = db.friendDao() + private val roomDao = db.roomLocalDao() + private val blockDao = db.blockedUserDao() + + suspend fun performSync(): Result { + return try { + val since = userPrefs.syncSinceFlow.first() + val response = api.sync(since) + + if (response.isSuccessful) { + val body = response.body() ?: return Result.failure(Exception("Sync body is null")) + + db.withTransaction { + if (body.fullSnapshot) { + friendDao.deleteAll() + roomDao.deleteAll() + blockDao.deleteAll() + } + + // Process Blocks + body.blocks.forEach { block -> + if (block.removed) { + blockDao.deleteById(block.userId) + } else { + blockDao.insertOrUpdate(listOf( + BlockedUserLocalEntity( + userId = block.userId, + username = block.username, + displayName = block.displayName, + avatarUrl = block.avatarUrl, + blockedAt = block.blockedAt + ) + )) + } + } + + // Process Friends + body.friends.forEach { friend -> + if (friend.removed) { + friendDao.deleteById(friend.userId) + } else { + friendDao.insertOrUpdate(listOf( + FriendLocalEntity( + userId = friend.userId, + username = friend.username, + displayName = friend.displayName, + avatarUrl = friend.avatarUrl, + status = friend.status, + presenceStatus = friend.presenceStatus + ) + )) + } + } + + // Process Rooms + body.rooms.forEach { room -> + if (room.removed) { + roomDao.deleteById(room.roomId) + } else { + roomDao.insertOrUpdate(listOf( + RoomLocalEntity( + roomId = room.roomId, + nickname = room.nickname, + isHidden = room.isHidden, + isMuted = room.isMuted, + isPinned = room.isPinned, + unreadCount = room.unreadCount, + mentionCount = room.mentionCount, + partner = room.partner, + lastMessage = room.lastMessage + ) + )) + } + } + + userPrefs.saveSyncSince(body.nextSince) + } + Result.success(Unit) + } else { + Result.failure(Exception("Sync API failed: ${response.code()}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt index fa85b84..9ceefb1 100644 --- a/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt +++ b/app/src/main/java/com/example/login_v3/data/repository/dm/DM_Basic_Repository.kt @@ -17,9 +17,9 @@ import com.example.login_v3.data.api.api_class.SendMessageRequest import com.example.login_v3.data.api.api_class.WebSocketEventResponse import com.example.login_v3.data.di.ChatWebSocketManager import com.example.login_v3.data.repository.basic.TokenManager -import com.example.login_v3.home.Message.ViewModel.Detail.MessageDao -import com.example.login_v3.home.Message.ViewModel.Detail.MessageEntity -import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus +import com.example.login_v3.data.local.dao.MessageDao +import com.example.login_v3.data.local.entities.MessageEntity +import com.example.login_v3.data.local.entities.MessageStatus import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import com.squareup.moshi.Moshi diff --git a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt index 5f3b4e6..9879c46 100644 --- a/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt +++ b/app/src/main/java/com/example/login_v3/home/Message/UI/Detail/Message_Messaging.kt @@ -72,7 +72,7 @@ import com.example.login_v3.home.Message.UI.Detail.Message_Component.ReactionRow import com.example.login_v3.home.Message.UI.Detail.Message_Component.UserAvatar import com.example.login_v3.home.Message.ViewModel.Detail.ChatViewModel import com.example.login_v3.home.Message.ViewModel.Detail.DeleteMessageState -import com.example.login_v3.home.Message.ViewModel.Detail.MessageStatus +import com.example.login_v3.data.local.entities.MessageStatus import com.example.login_v3.home.Message.ViewModel.Detail.MessagesUiState import com.example.login_v3.home.Message.ViewModel.Detail.SendMessageState import com.example.login_v3.navigation.BottomBarViewModel diff --git a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Cache.kt b/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Cache.kt deleted file mode 100644 index 8ec00bf..0000000 --- a/app/src/main/java/com/example/login_v3/home/Message/ViewModel/Detail/Message_Cache.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.example.login_v3.home.Message.ViewModel.Detail - -import androidx.room.Dao -import androidx.room.Entity -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.PrimaryKey -import kotlinx.coroutines.flow.Flow -import androidx.room.Query -import androidx.room.Transaction -import com.example.login_v3.data.api.api_class.Reaction - -enum class MessageStatus { SENDING, SUCCESS, FAILED } - -@Entity(tableName = "messages") -data class MessageEntity( - @PrimaryKey val id: String, - val chatRoomId: String, // 👈 對齊你的 @Json(name = "chat_room_id") - val senderId: String, - val content: String, - val type: String, // 👈 新增:文字、圖片等類型 - val createdAt: String, // 👈 核心修正:改成與 API 一致的 String 格式 - val isEdited: Boolean, // 👈 新增 - val isDeleted: Boolean, // 👈 新增 - val replyToId: String?, // 👈 新增 - val status: MessageStatus, // 👈 樂觀更新狀態控制 (SENDING, SUCCESS, FAILED) - val reactions: List? -) - -@Dao -interface MessageDao { - // 1. 根據 roomId 監聽整個聊天室的訊息 Flow - @Query("SELECT * FROM messages WHERE chatRoomId = :roomId ORDER BY createdAt DESC") - fun getMessagesFlow(roomId: String): Flow> - - // 2. ✨【新增】根據訊息 id 查詢單筆資料(樂觀更新備份用) - @Query("SELECT * FROM messages WHERE id = :messageId LIMIT 1") - suspend fun getMessageById(messageId: String): MessageEntity? - - // 3. 插入或更新單筆訊息ssss - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertOrUpdate(message: MessageEntity) - - // 4. ✨【優化】支援批次插入或更新(可以順便把 refreshChatMessages 裡面的 forEach 換掉,效能更好) - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertOrUpdateList(messages: List) - - // 5. 根據 id 刪除特定訊息 - @Query("DELETE FROM messages WHERE id = :id") - suspend fun deleteById(id: String) - - @Transaction - suspend fun replaceTempMessageWithSuccess(tempId: String, successEntity: MessageEntity) { - deleteById(tempId) // 1. 先刪除本地暫存 ID - insertOrUpdate(successEntity) // 2. 立刻寫入後端正式 ID - } -} \ No newline at end of file