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

View file

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

View file

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

View file

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

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