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:
Kohei 2026-02-18 21:57:45 +09:00
parent 86e7db965f
commit 0179eba2ed
7 changed files with 50 additions and 17 deletions

View file

@ -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.remote.WebSocketClient
import io.picoclaw.android.core.data.repository.ChatRepositoryImpl import io.picoclaw.android.core.data.repository.ChatRepositoryImpl
import io.picoclaw.android.core.domain.repository.ChatRepository 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.LoadMoreMessagesUseCase
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
@ -33,7 +35,7 @@ val appModule = module {
androidContext(), androidContext(),
AppDatabase::class.java, AppDatabase::class.java,
"picoclaw.db" "picoclaw.db"
).build() ).fallbackToDestructiveMigration(dropAllTables = true).build()
} }
single { get<AppDatabase>().messageDao() } single { get<AppDatabase>().messageDao() }
@ -50,7 +52,7 @@ val appModule = module {
} }
// WebSocketClient // WebSocketClient
single { WebSocketClient(get()) } single { WebSocketClient(get(), get()) }
// ImageFileStorage // ImageFileStorage
single { ImageFileStorage(androidContext()) } single { ImageFileStorage(androidContext()) }
@ -63,7 +65,9 @@ val appModule = module {
factory { ObserveMessagesUseCase(get()) } factory { ObserveMessagesUseCase(get()) }
factory { ObserveConnectionUseCase(get()) } factory { ObserveConnectionUseCase(get()) }
factory { LoadMoreMessagesUseCase(get()) } factory { LoadMoreMessagesUseCase(get()) }
factory { ConnectChatUseCase(get()) }
factory { DisconnectChatUseCase(get()) }
// ViewModel // ViewModel
viewModel { ChatViewModel(get(), get(), get(), get(), get()) } viewModel { ChatViewModel(get(), get(), get(), get(), get(), get()) }
} }

View file

@ -5,6 +5,8 @@ import android.graphics.BitmapFactory
import android.net.Uri import android.net.Uri
import android.util.Base64 import android.util.Base64
import io.picoclaw.android.core.domain.model.ImageData import io.picoclaw.android.core.domain.model.ImageData
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
@ -17,7 +19,7 @@ class ImageFileStorage(private val context: Context) {
val base64: String 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 { val bytes = context.contentResolver.openInputStream(Uri.parse(uriString))?.use {
it.readBytes() it.readBytes()
} ?: error("Cannot read URI: $uriString") } ?: error("Cannot read URI: $uriString")
@ -28,7 +30,7 @@ class ImageFileStorage(private val context: Context) {
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true } val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(file.absolutePath, opts) BitmapFactory.decodeFile(file.absolutePath, opts)
return SaveResult( SaveResult(
imageData = ImageData(file.absolutePath, opts.outWidth, opts.outHeight), imageData = ImageData(file.absolutePath, opts.outWidth, opts.outHeight),
base64 = Base64.encodeToString(bytes, Base64.NO_WRAP) base64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
) )

View file

@ -1,5 +1,6 @@
package io.picoclaw.android.core.data.mapper 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.local.entity.MessageEntity
import io.picoclaw.android.core.data.remote.dto.WsIncoming import io.picoclaw.android.core.data.remote.dto.WsIncoming
import io.picoclaw.android.core.data.remote.dto.WsOutgoing import io.picoclaw.android.core.data.remote.dto.WsOutgoing
@ -23,7 +24,8 @@ object MessageMapper {
Json.decodeFromString<List<ImageEntry>>(it).map { e -> Json.decodeFromString<List<ImageEntry>>(it).map { e ->
ImageData(e.path, e.width, e.height) ImageData(e.path, e.width, e.height)
} }
} catch (_: Exception) { } catch (e: Exception) {
Log.w("MessageMapper", "Failed to parse image path list", e)
emptyList() emptyList()
} }
} ?: emptyList() } ?: emptyList()

View file

@ -1,5 +1,6 @@
package io.picoclaw.android.core.data.remote package io.picoclaw.android.core.data.remote
import android.util.Log
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.plugins.websocket.webSocket
import io.ktor.websocket.Frame 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.data.remote.dto.WsOutgoing
import io.picoclaw.android.core.domain.model.ConnectionState import io.picoclaw.android.core.domain.model.ConnectionState
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -24,7 +23,10 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json 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) private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@ -35,7 +37,6 @@ class WebSocketClient(private val client: HttpClient) {
private var session: WebSocketSession? = null private var session: WebSocketSession? = null
private var connectJob: Job? = null private var connectJob: Job? = null
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
var wsUrl: String = "ws://127.0.0.1:18793/ws" var wsUrl: String = "ws://127.0.0.1:18793/ws"
@ -56,12 +57,14 @@ class WebSocketClient(private val client: HttpClient) {
try { try {
val msg = json.decodeFromString<WsOutgoing>(text) val msg = json.decodeFromString<WsOutgoing>(text)
_incomingMessages.emit(msg) _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 session = null
_connectionState.value = ConnectionState.RECONNECTING _connectionState.value = ConnectionState.RECONNECTING
@ -82,12 +85,14 @@ class WebSocketClient(private val client: HttpClient) {
return try { return try {
session?.send(Frame.Text(json.encodeToString(dto))) session?.send(Frame.Text(json.encodeToString(dto)))
true true
} catch (_: Exception) { } catch (e: Exception) {
Log.w(TAG, "Failed to send WebSocket message", e)
false false
} }
} }
companion object { companion object {
private const val TAG = "WebSocketClient"
private const val INITIAL_DELAY = 1000L private const val INITIAL_DELAY = 1000L
private const val MAX_DELAY = 30000L private const val MAX_DELAY = 30000L
} }

View file

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

View file

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

View file

@ -2,7 +2,8 @@ package io.picoclaw.android.feature.chat
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.LoadMoreMessagesUseCase
import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
@ -18,14 +19,15 @@ class ChatViewModel(
private val observeMessages: ObserveMessagesUseCase, private val observeMessages: ObserveMessagesUseCase,
private val observeConnection: ObserveConnectionUseCase, private val observeConnection: ObserveConnectionUseCase,
private val loadMoreMessages: LoadMoreMessagesUseCase, private val loadMoreMessages: LoadMoreMessagesUseCase,
private val repository: ChatRepository private val connectChat: ConnectChatUseCase,
private val disconnectChat: DisconnectChatUseCase
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(ChatUiState()) private val _uiState = MutableStateFlow(ChatUiState())
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
init { init {
repository.connect() connectChat()
viewModelScope.launch { viewModelScope.launch {
observeMessages().collect { messages -> observeMessages().collect { messages ->
@ -80,6 +82,6 @@ class ChatViewModel(
override fun onCleared() { override fun onCleared() {
super.onCleared() super.onCleared()
repository.disconnect() disconnectChat()
} }
} }