diff --git a/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt b/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt index 40631c6d0..c2f8e27ed 100644 --- a/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt +++ b/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt @@ -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().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()) } } diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt index 96e8aa163..504718efe 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/ImageFileStorage.kt @@ -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) ) diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt index 8df0f31f5..bbf71b6f4 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt @@ -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>(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() diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt index 3f216413d..eac1c0a5f 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt @@ -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.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(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 } diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ConnectChatUseCase.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ConnectChatUseCase.kt new file mode 100644 index 000000000..3ee56693d --- /dev/null +++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ConnectChatUseCase.kt @@ -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() + } +} diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/DisconnectChatUseCase.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/DisconnectChatUseCase.kt new file mode 100644 index 000000000..91488bf7c --- /dev/null +++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/DisconnectChatUseCase.kt @@ -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() + } +} diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt index f2faf4515..200f622a5 100644 --- a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt +++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt @@ -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 = _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() } }