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.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()) }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue