refactor: improve architecture consistency and robustness
- Add ConnectChatUseCase/DisconnectChatUseCase to eliminate direct Repository dependency from ViewModel - Make ImageFileStorage.saveFromUri suspend with withContext(Dispatchers.IO) to avoid blocking - Inject CoroutineScope into WebSocketClient instead of internal creation for testability - Add Log.w to all silently swallowed exceptions in WebSocketClient and MessageMapper - Add fallbackToDestructiveMigration to Room database builder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
86e7db965f
commit
0179eba2ed
7 changed files with 50 additions and 17 deletions
|
|
@ -9,6 +9,8 @@ import io.picoclaw.android.core.data.local.ImageFileStorage
|
|||
import io.picoclaw.android.core.data.remote.WebSocketClient
|
||||
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
|
||||
import io.picoclaw.android.core.domain.repository.ChatRepository
|
||||
import io.picoclaw.android.core.domain.usecase.ConnectChatUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
||||
|
|
@ -33,7 +35,7 @@ val appModule = module {
|
|||
androidContext(),
|
||||
AppDatabase::class.java,
|
||||
"picoclaw.db"
|
||||
).build()
|
||||
).fallbackToDestructiveMigration(dropAllTables = true).build()
|
||||
}
|
||||
single { get<AppDatabase>().messageDao() }
|
||||
|
||||
|
|
@ -50,7 +52,7 @@ val appModule = module {
|
|||
}
|
||||
|
||||
// WebSocketClient
|
||||
single { WebSocketClient(get()) }
|
||||
single { WebSocketClient(get(), get()) }
|
||||
|
||||
// ImageFileStorage
|
||||
single { ImageFileStorage(androidContext()) }
|
||||
|
|
@ -63,7 +65,9 @@ val appModule = module {
|
|||
factory { ObserveMessagesUseCase(get()) }
|
||||
factory { ObserveConnectionUseCase(get()) }
|
||||
factory { LoadMoreMessagesUseCase(get()) }
|
||||
factory { ConnectChatUseCase(get()) }
|
||||
factory { DisconnectChatUseCase(get()) }
|
||||
|
||||
// ViewModel
|
||||
viewModel { ChatViewModel(get(), get(), get(), get(), get()) }
|
||||
viewModel { ChatViewModel(get(), get(), get(), get(), get(), get()) }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import android.graphics.BitmapFactory
|
|||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
import io.picoclaw.android.core.domain.model.ImageData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
|
|
@ -17,7 +19,7 @@ class ImageFileStorage(private val context: Context) {
|
|||
val base64: String
|
||||
)
|
||||
|
||||
fun saveFromUri(uriString: String): SaveResult {
|
||||
suspend fun saveFromUri(uriString: String): SaveResult = withContext(Dispatchers.IO) {
|
||||
val bytes = context.contentResolver.openInputStream(Uri.parse(uriString))?.use {
|
||||
it.readBytes()
|
||||
} ?: error("Cannot read URI: $uriString")
|
||||
|
|
@ -28,7 +30,7 @@ class ImageFileStorage(private val context: Context) {
|
|||
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeFile(file.absolutePath, opts)
|
||||
|
||||
return SaveResult(
|
||||
SaveResult(
|
||||
imageData = ImageData(file.absolutePath, opts.outWidth, opts.outHeight),
|
||||
base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package io.picoclaw.android.core.data.mapper
|
||||
|
||||
import android.util.Log
|
||||
import io.picoclaw.android.core.data.local.entity.MessageEntity
|
||||
import io.picoclaw.android.core.data.remote.dto.WsIncoming
|
||||
import io.picoclaw.android.core.data.remote.dto.WsOutgoing
|
||||
|
|
@ -23,7 +24,8 @@ object MessageMapper {
|
|||
Json.decodeFromString<List<ImageEntry>>(it).map { e ->
|
||||
ImageData(e.path, e.width, e.height)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Log.w("MessageMapper", "Failed to parse image path list", e)
|
||||
emptyList()
|
||||
}
|
||||
} ?: emptyList()
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package io.picoclaw.android.core.data.remote
|
||||
|
||||
import android.util.Log
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.plugins.websocket.webSocket
|
||||
import io.ktor.websocket.Frame
|
||||
|
|
@ -9,9 +10,7 @@ import io.picoclaw.android.core.data.remote.dto.WsIncoming
|
|||
import io.picoclaw.android.core.data.remote.dto.WsOutgoing
|
||||
import io.picoclaw.android.core.domain.model.ConnectionState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -24,7 +23,10 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class WebSocketClient(private val client: HttpClient) {
|
||||
class WebSocketClient(
|
||||
private val client: HttpClient,
|
||||
private val scope: CoroutineScope
|
||||
) {
|
||||
|
||||
private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
|
@ -35,7 +37,6 @@ class WebSocketClient(private val client: HttpClient) {
|
|||
private var session: WebSocketSession? = null
|
||||
private var connectJob: Job? = null
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
var wsUrl: String = "ws://127.0.0.1:18793/ws"
|
||||
|
||||
|
|
@ -56,12 +57,14 @@ class WebSocketClient(private val client: HttpClient) {
|
|||
try {
|
||||
val msg = json.decodeFromString<WsOutgoing>(text)
|
||||
_incomingMessages.emit(msg)
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to parse WebSocket message", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "WebSocket connection error", e)
|
||||
}
|
||||
session = null
|
||||
_connectionState.value = ConnectionState.RECONNECTING
|
||||
|
|
@ -82,12 +85,14 @@ class WebSocketClient(private val client: HttpClient) {
|
|||
return try {
|
||||
session?.send(Frame.Text(json.encodeToString(dto)))
|
||||
true
|
||||
} catch (_: Exception) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to send WebSocket message", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "WebSocketClient"
|
||||
private const val INITIAL_DELAY = 1000L
|
||||
private const val MAX_DELAY = 30000L
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package io.picoclaw.android.core.domain.usecase
|
||||
|
||||
import io.picoclaw.android.core.domain.repository.ChatRepository
|
||||
|
||||
class ConnectChatUseCase(private val repository: ChatRepository) {
|
||||
operator fun invoke() {
|
||||
repository.connect()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
package io.picoclaw.android.core.domain.usecase
|
||||
|
||||
import io.picoclaw.android.core.domain.repository.ChatRepository
|
||||
|
||||
class DisconnectChatUseCase(private val repository: ChatRepository) {
|
||||
operator fun invoke() {
|
||||
repository.disconnect()
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@ package io.picoclaw.android.feature.chat
|
|||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.picoclaw.android.core.domain.repository.ChatRepository
|
||||
import io.picoclaw.android.core.domain.usecase.ConnectChatUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.DisconnectChatUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.LoadMoreMessagesUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
|
||||
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
|
||||
|
|
@ -18,14 +19,15 @@ class ChatViewModel(
|
|||
private val observeMessages: ObserveMessagesUseCase,
|
||||
private val observeConnection: ObserveConnectionUseCase,
|
||||
private val loadMoreMessages: LoadMoreMessagesUseCase,
|
||||
private val repository: ChatRepository
|
||||
private val connectChat: ConnectChatUseCase,
|
||||
private val disconnectChat: DisconnectChatUseCase
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
init {
|
||||
repository.connect()
|
||||
connectChat()
|
||||
|
||||
viewModelScope.launch {
|
||||
observeMessages().collect { messages ->
|
||||
|
|
@ -80,6 +82,6 @@ class ChatViewModel(
|
|||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
repository.disconnect()
|
||||
disconnectChat()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue