diff --git a/.gitignore b/.gitignore
index ce30d749e..08783ba40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -42,5 +42,12 @@ tasks/
.vscode/
.idea/
+# Android
+*.apk
+*.aab
+*.keystore
+*.jks
+local.properties
+
# Added by goreleaser init:
dist/
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 000000000..56ce17a7a
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,13 @@
+*.iml
+.gradle
+/local.properties
+/.idea
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+/app/build
+/feature/*/build
+/core/*/build
diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts
new file mode 100644
index 000000000..9149c4fd0
--- /dev/null
+++ b/android/app/build.gradle.kts
@@ -0,0 +1,64 @@
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.compose.compiler)
+ alias(libs.plugins.serialization)
+}
+
+android {
+ namespace = "io.picoclaw.android"
+ compileSdk = 35
+
+ defaultConfig {
+ applicationId = "io.picoclaw.android"
+ minSdk = 28
+ targetSdk = 35
+ versionCode = 1
+ versionName = "1.0.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation(project(":feature:chat"))
+ implementation(project(":core:domain"))
+ implementation(project(":core:data"))
+ implementation(project(":core:ui"))
+
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.ui)
+ implementation(libs.compose.material3)
+ implementation(libs.activity.compose)
+ implementation(libs.core.ktx)
+ implementation(libs.lifecycle.runtime.compose)
+
+ implementation(libs.koin.android)
+ implementation(libs.koin.compose)
+
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.ktor.client.websockets)
+
+ implementation(libs.room.runtime)
+ implementation(libs.room.ktx)
+
+ implementation(libs.coroutines.android)
+
+ debugImplementation(libs.compose.ui.tooling)
+}
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 000000000..226da85ae
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,6 @@
+-keepattributes *Annotation*, InnerClasses
+-dontnote kotlinx.serialization.AnnotationsKt
+-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
+-keepclasseswithmembers class io.picoclaw.android.** {
+ kotlinx.serialization.KSerializer serializer(...);
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..6a6ba1bf9
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/io/picoclaw/android/MainActivity.kt b/android/app/src/main/java/io/picoclaw/android/MainActivity.kt
new file mode 100644
index 000000000..8d1857617
--- /dev/null
+++ b/android/app/src/main/java/io/picoclaw/android/MainActivity.kt
@@ -0,0 +1,20 @@
+package io.picoclaw.android
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import io.picoclaw.android.core.ui.theme.PicoClawTheme
+import io.picoclaw.android.feature.chat.screen.ChatScreen
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+ setContent {
+ PicoClawTheme {
+ ChatScreen()
+ }
+ }
+ }
+}
diff --git a/android/app/src/main/java/io/picoclaw/android/PicoClawApp.kt b/android/app/src/main/java/io/picoclaw/android/PicoClawApp.kt
new file mode 100644
index 000000000..b4d3be91e
--- /dev/null
+++ b/android/app/src/main/java/io/picoclaw/android/PicoClawApp.kt
@@ -0,0 +1,16 @@
+package io.picoclaw.android
+
+import android.app.Application
+import io.picoclaw.android.di.appModule
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.startKoin
+
+class PicoClawApp : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ startKoin {
+ androidContext(this@PicoClawApp)
+ modules(appModule)
+ }
+ }
+}
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
new file mode 100644
index 000000000..9ac3be9b0
--- /dev/null
+++ b/android/app/src/main/java/io/picoclaw/android/di/AppModule.kt
@@ -0,0 +1,65 @@
+package io.picoclaw.android.di
+
+import androidx.room.Room
+import io.ktor.client.HttpClient
+import io.ktor.client.engine.okhttp.OkHttp
+import io.ktor.client.plugins.websocket.WebSockets
+import io.picoclaw.android.core.data.local.AppDatabase
+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.LoadMoreMessagesUseCase
+import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
+import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
+import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
+import io.picoclaw.android.feature.chat.ChatViewModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import okhttp3.OkHttpClient
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.module.dsl.viewModel
+import org.koin.dsl.module
+import java.util.concurrent.TimeUnit
+
+val appModule = module {
+ // CoroutineScope
+ single { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
+
+ // Room
+ single {
+ Room.databaseBuilder(
+ androidContext(),
+ AppDatabase::class.java,
+ "picoclaw.db"
+ ).build()
+ }
+ single { get().messageDao() }
+
+ // Ktor HttpClient
+ single {
+ HttpClient(OkHttp) {
+ install(WebSockets)
+ engine {
+ preconfigured = OkHttpClient.Builder()
+ .pingInterval(30, TimeUnit.SECONDS)
+ .build()
+ }
+ }
+ }
+
+ // WebSocketClient
+ single { WebSocketClient(get()) }
+
+ // Repository
+ single { ChatRepositoryImpl(get(), get(), get()) }
+
+ // UseCases
+ factory { SendMessageUseCase(get()) }
+ factory { ObserveMessagesUseCase(get()) }
+ factory { ObserveConnectionUseCase(get()) }
+ factory { LoadMoreMessagesUseCase(get()) }
+
+ // ViewModel
+ viewModel { ChatViewModel(get(), get(), get(), get(), get()) }
+}
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..526d293ac
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..768b058a6
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #FF000000
+ #FFFFFFFF
+
diff --git a/android/app/src/main/res/values/ic_launcher_colors.xml b/android/app/src/main/res/values/ic_launcher_colors.xml
new file mode 100644
index 000000000..e8bc98809
--- /dev/null
+++ b/android/app/src/main/res/values/ic_launcher_colors.xml
@@ -0,0 +1,5 @@
+
+
+ #2196F3
+ #FFFFFF
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..9652097f9
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+
+ PicoClaw
+
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..92c14c2ad
--- /dev/null
+++ b/android/app/src/main/res/values/themes.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
new file mode 100644
index 000000000..5ade7336e
--- /dev/null
+++ b/android/app/src/main/res/xml/network_security_config.xml
@@ -0,0 +1,7 @@
+
+
+
+ 127.0.0.1
+ localhost
+
+
diff --git a/android/build.gradle.kts b/android/build.gradle.kts
new file mode 100644
index 000000000..811ba1f58
--- /dev/null
+++ b/android/build.gradle.kts
@@ -0,0 +1,7 @@
+plugins {
+ alias(libs.plugins.android.application) apply false
+ alias(libs.plugins.android.library) apply false
+ alias(libs.plugins.compose.compiler) apply false
+ alias(libs.plugins.serialization) apply false
+ alias(libs.plugins.ksp) apply false
+}
diff --git a/android/core/data/build.gradle.kts b/android/core/data/build.gradle.kts
new file mode 100644
index 000000000..b422394cb
--- /dev/null
+++ b/android/core/data/build.gradle.kts
@@ -0,0 +1,35 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.serialization)
+ alias(libs.plugins.ksp)
+}
+
+android {
+ namespace = "io.picoclaw.android.core.data"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 28
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation(project(":core:domain"))
+
+ implementation(libs.ktor.client.okhttp)
+ implementation(libs.ktor.client.websockets)
+ implementation(libs.ktor.client.content.negotiation)
+ implementation(libs.ktor.serialization.json)
+ implementation(libs.serialization.json)
+
+ implementation(libs.room.runtime)
+ implementation(libs.room.ktx)
+ ksp(libs.room.compiler)
+
+ implementation(libs.coroutines.android)
+}
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/AppDatabase.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/AppDatabase.kt
new file mode 100644
index 000000000..65c104f7b
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/AppDatabase.kt
@@ -0,0 +1,14 @@
+package io.picoclaw.android.core.data.local
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import io.picoclaw.android.core.data.local.converter.Converters
+import io.picoclaw.android.core.data.local.dao.MessageDao
+import io.picoclaw.android.core.data.local.entity.MessageEntity
+
+@Database(entities = [MessageEntity::class], version = 1)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun messageDao(): MessageDao
+}
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/converter/Converters.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/converter/Converters.kt
new file mode 100644
index 000000000..39d0cda01
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/converter/Converters.kt
@@ -0,0 +1,17 @@
+package io.picoclaw.android.core.data.local.converter
+
+import androidx.room.TypeConverter
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+class Converters {
+ @TypeConverter
+ fun fromStringList(value: List?): String? {
+ return value?.let { Json.encodeToString(it) }
+ }
+
+ @TypeConverter
+ fun toStringList(value: String?): List? {
+ return value?.let { Json.decodeFromString(it) }
+ }
+}
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/dao/MessageDao.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/dao/MessageDao.kt
new file mode 100644
index 000000000..3dd10288f
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/dao/MessageDao.kt
@@ -0,0 +1,24 @@
+package io.picoclaw.android.core.data.local.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Update
+import io.picoclaw.android.core.data.local.entity.MessageEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface MessageDao {
+ @Query("SELECT * FROM messages ORDER BY timestamp DESC LIMIT :limit")
+ fun getRecentMessages(limit: Int): Flow>
+
+ @Query("SELECT * FROM messages WHERE timestamp < :beforeTimestamp ORDER BY timestamp DESC LIMIT :limit")
+ suspend fun getMessagesBefore(beforeTimestamp: Long, limit: Int): List
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(message: MessageEntity)
+
+ @Update
+ suspend fun update(message: MessageEntity)
+}
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/local/entity/MessageEntity.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/entity/MessageEntity.kt
new file mode 100644
index 000000000..dd9978530
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/local/entity/MessageEntity.kt
@@ -0,0 +1,14 @@
+package io.picoclaw.android.core.data.local.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "messages")
+data class MessageEntity(
+ @PrimaryKey val id: String,
+ val content: String,
+ val sender: String,
+ val imageBase64List: String?,
+ val timestamp: Long,
+ val status: String
+)
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
new file mode 100644
index 000000000..b2fc601d6
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/mapper/MessageMapper.kt
@@ -0,0 +1,67 @@
+package io.picoclaw.android.core.data.mapper
+
+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
+import io.picoclaw.android.core.domain.model.ChatMessage
+import io.picoclaw.android.core.domain.model.ImageAttachment
+import io.picoclaw.android.core.domain.model.MessageSender
+import io.picoclaw.android.core.domain.model.MessageStatus
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import java.util.UUID
+
+object MessageMapper {
+
+ fun toDomain(entity: MessageEntity): ChatMessage {
+ val images = entity.imageBase64List?.let {
+ try {
+ Json.decodeFromString>(it)
+ } catch (_: Exception) {
+ emptyList()
+ }
+ } ?: emptyList()
+
+ return ChatMessage(
+ id = entity.id,
+ content = entity.content,
+ sender = MessageSender.valueOf(entity.sender),
+ images = images,
+ timestamp = entity.timestamp,
+ status = MessageStatus.valueOf(entity.status)
+ )
+ }
+
+ fun toEntity(dto: WsOutgoing): MessageEntity {
+ return MessageEntity(
+ id = UUID.randomUUID().toString(),
+ content = dto.content,
+ sender = MessageSender.AGENT.name,
+ imageBase64List = null,
+ timestamp = System.currentTimeMillis(),
+ status = MessageStatus.RECEIVED.name
+ )
+ }
+
+ fun toEntity(text: String, images: List, status: MessageStatus): MessageEntity {
+ val imageJson = if (images.isNotEmpty()) {
+ Json.encodeToString(images.map { it.base64 })
+ } else null
+
+ return MessageEntity(
+ id = UUID.randomUUID().toString(),
+ content = text,
+ sender = MessageSender.USER.name,
+ imageBase64List = imageJson,
+ timestamp = System.currentTimeMillis(),
+ status = status.name
+ )
+ }
+
+ fun toWsIncoming(text: String, images: List): WsIncoming {
+ return WsIncoming(
+ content = text,
+ images = if (images.isNotEmpty()) images.map { it.base64 } else null
+ )
+ }
+}
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
new file mode 100644
index 000000000..3f216413d
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/WebSocketClient.kt
@@ -0,0 +1,94 @@
+package io.picoclaw.android.core.data.remote
+
+import io.ktor.client.HttpClient
+import io.ktor.client.plugins.websocket.webSocket
+import io.ktor.websocket.Frame
+import io.ktor.websocket.WebSocketSession
+import io.ktor.websocket.readText
+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
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+class WebSocketClient(private val client: HttpClient) {
+
+ private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED)
+ val connectionState: StateFlow = _connectionState.asStateFlow()
+
+ private val _incomingMessages = MutableSharedFlow(extraBufferCapacity = 64)
+ val incomingMessages: SharedFlow = _incomingMessages.asSharedFlow()
+
+ 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"
+
+ fun connect() {
+ if (connectJob?.isActive == true) return
+ connectJob = scope.launch {
+ var retryDelay = INITIAL_DELAY
+ while (isActive) {
+ try {
+ _connectionState.value = ConnectionState.CONNECTING
+ client.webSocket(wsUrl) {
+ session = this
+ _connectionState.value = ConnectionState.CONNECTED
+ retryDelay = INITIAL_DELAY
+ for (frame in incoming) {
+ if (frame is Frame.Text) {
+ val text = frame.readText()
+ try {
+ val msg = json.decodeFromString(text)
+ _incomingMessages.emit(msg)
+ } catch (_: Exception) {
+ }
+ }
+ }
+ }
+ } catch (_: Exception) {
+ }
+ session = null
+ _connectionState.value = ConnectionState.RECONNECTING
+ delay(retryDelay)
+ retryDelay = (retryDelay * 2).coerceAtMost(MAX_DELAY)
+ }
+ }
+ }
+
+ fun disconnect() {
+ connectJob?.cancel()
+ connectJob = null
+ session = null
+ _connectionState.value = ConnectionState.DISCONNECTED
+ }
+
+ suspend fun send(dto: WsIncoming): Boolean {
+ return try {
+ session?.send(Frame.Text(json.encodeToString(dto)))
+ true
+ } catch (_: Exception) {
+ false
+ }
+ }
+
+ companion object {
+ private const val INITIAL_DELAY = 1000L
+ private const val MAX_DELAY = 30000L
+ }
+}
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsIncoming.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsIncoming.kt
new file mode 100644
index 000000000..ae53f91c3
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsIncoming.kt
@@ -0,0 +1,11 @@
+package io.picoclaw.android.core.data.remote.dto
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class WsIncoming(
+ val content: String,
+ @SerialName("sender_id") val senderId: String? = null,
+ val images: List? = null
+)
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsOutgoing.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsOutgoing.kt
new file mode 100644
index 000000000..413a14402
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/remote/dto/WsOutgoing.kt
@@ -0,0 +1,6 @@
+package io.picoclaw.android.core.data.remote.dto
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class WsOutgoing(val content: String)
diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt
new file mode 100644
index 000000000..4f39b0b5f
--- /dev/null
+++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/ChatRepositoryImpl.kt
@@ -0,0 +1,72 @@
+package io.picoclaw.android.core.data.repository
+
+import io.picoclaw.android.core.data.local.dao.MessageDao
+import io.picoclaw.android.core.data.mapper.MessageMapper
+import io.picoclaw.android.core.data.remote.WebSocketClient
+import io.picoclaw.android.core.domain.model.ChatMessage
+import io.picoclaw.android.core.domain.model.ConnectionState
+import io.picoclaw.android.core.domain.model.ImageAttachment
+import io.picoclaw.android.core.domain.model.MessageStatus
+import io.picoclaw.android.core.domain.repository.ChatRepository
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class ChatRepositoryImpl(
+ private val webSocketClient: WebSocketClient,
+ private val messageDao: MessageDao,
+ private val scope: CoroutineScope
+) : ChatRepository {
+
+ private val _displayLimit = MutableStateFlow(INITIAL_LOAD_COUNT)
+
+ @Suppress("OPT_IN_USAGE")
+ override val messages: StateFlow> =
+ _displayLimit.flatMapLatest { limit ->
+ messageDao.getRecentMessages(limit)
+ }.map { entities ->
+ entities.map { MessageMapper.toDomain(it) }.reversed()
+ }.stateIn(scope, SharingStarted.Lazily, emptyList())
+
+ override val connectionState: StateFlow = webSocketClient.connectionState
+
+ init {
+ scope.launch {
+ webSocketClient.incomingMessages.collect { dto ->
+ val entity = MessageMapper.toEntity(dto)
+ messageDao.insert(entity)
+ }
+ }
+ }
+
+ override suspend fun sendMessage(text: String, images: List) {
+ val entity = MessageMapper.toEntity(text, images, MessageStatus.SENDING)
+ messageDao.insert(entity)
+ val wsDto = MessageMapper.toWsIncoming(text, images)
+ val success = webSocketClient.send(wsDto)
+ messageDao.update(entity.copy(status = if (success) MessageStatus.SENT.name else MessageStatus.FAILED.name))
+ }
+
+ override fun loadMore() {
+ _displayLimit.update { it + PAGE_SIZE }
+ }
+
+ override fun connect() {
+ webSocketClient.connect()
+ }
+
+ override fun disconnect() {
+ webSocketClient.disconnect()
+ }
+
+ companion object {
+ const val INITIAL_LOAD_COUNT = 50
+ const val PAGE_SIZE = 30
+ }
+}
diff --git a/android/core/domain/build.gradle.kts b/android/core/domain/build.gradle.kts
new file mode 100644
index 000000000..3f8621fe9
--- /dev/null
+++ b/android/core/domain/build.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+ alias(libs.plugins.android.library)
+}
+
+android {
+ namespace = "io.picoclaw.android.core.domain"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 28
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation(libs.coroutines.android)
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ChatMessage.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ChatMessage.kt
new file mode 100644
index 000000000..81c30c4a5
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ChatMessage.kt
@@ -0,0 +1,14 @@
+package io.picoclaw.android.core.domain.model
+
+data class ChatMessage(
+ val id: String,
+ val content: String,
+ val sender: MessageSender,
+ val images: List = emptyList(),
+ val timestamp: Long,
+ val status: MessageStatus
+)
+
+enum class MessageSender { USER, AGENT }
+
+enum class MessageStatus { SENDING, SENT, FAILED, RECEIVED }
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ConnectionState.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ConnectionState.kt
new file mode 100644
index 000000000..eecc14cac
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ConnectionState.kt
@@ -0,0 +1,5 @@
+package io.picoclaw.android.core.domain.model
+
+enum class ConnectionState {
+ DISCONNECTED, CONNECTING, CONNECTED, RECONNECTING
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ImageAttachment.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ImageAttachment.kt
new file mode 100644
index 000000000..0b06f6336
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/model/ImageAttachment.kt
@@ -0,0 +1,7 @@
+package io.picoclaw.android.core.domain.model
+
+data class ImageAttachment(
+ val uri: String? = null,
+ val base64: String,
+ val mimeType: String = "image/png"
+)
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/ChatRepository.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/ChatRepository.kt
new file mode 100644
index 000000000..31e76e962
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/repository/ChatRepository.kt
@@ -0,0 +1,15 @@
+package io.picoclaw.android.core.domain.repository
+
+import io.picoclaw.android.core.domain.model.ChatMessage
+import io.picoclaw.android.core.domain.model.ConnectionState
+import io.picoclaw.android.core.domain.model.ImageAttachment
+import kotlinx.coroutines.flow.StateFlow
+
+interface ChatRepository {
+ val messages: StateFlow>
+ val connectionState: StateFlow
+ suspend fun sendMessage(text: String, images: List = emptyList())
+ fun loadMore()
+ fun connect()
+ fun disconnect()
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/LoadMoreMessagesUseCase.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/LoadMoreMessagesUseCase.kt
new file mode 100644
index 000000000..4f9caa4f7
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/LoadMoreMessagesUseCase.kt
@@ -0,0 +1,9 @@
+package io.picoclaw.android.core.domain.usecase
+
+import io.picoclaw.android.core.domain.repository.ChatRepository
+
+class LoadMoreMessagesUseCase(private val repository: ChatRepository) {
+ operator fun invoke() {
+ repository.loadMore()
+ }
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveConnectionUseCase.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveConnectionUseCase.kt
new file mode 100644
index 000000000..45d18d21d
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveConnectionUseCase.kt
@@ -0,0 +1,9 @@
+package io.picoclaw.android.core.domain.usecase
+
+import io.picoclaw.android.core.domain.model.ConnectionState
+import io.picoclaw.android.core.domain.repository.ChatRepository
+import kotlinx.coroutines.flow.StateFlow
+
+class ObserveConnectionUseCase(private val repository: ChatRepository) {
+ operator fun invoke(): StateFlow = repository.connectionState
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveMessagesUseCase.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveMessagesUseCase.kt
new file mode 100644
index 000000000..aba4e27e0
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/ObserveMessagesUseCase.kt
@@ -0,0 +1,9 @@
+package io.picoclaw.android.core.domain.usecase
+
+import io.picoclaw.android.core.domain.model.ChatMessage
+import io.picoclaw.android.core.domain.repository.ChatRepository
+import kotlinx.coroutines.flow.StateFlow
+
+class ObserveMessagesUseCase(private val repository: ChatRepository) {
+ operator fun invoke(): StateFlow> = repository.messages
+}
diff --git a/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/SendMessageUseCase.kt b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/SendMessageUseCase.kt
new file mode 100644
index 000000000..49eb8a02e
--- /dev/null
+++ b/android/core/domain/src/main/java/io/picoclaw/android/core/domain/usecase/SendMessageUseCase.kt
@@ -0,0 +1,10 @@
+package io.picoclaw.android.core.domain.usecase
+
+import io.picoclaw.android.core.domain.model.ImageAttachment
+import io.picoclaw.android.core.domain.repository.ChatRepository
+
+class SendMessageUseCase(private val repository: ChatRepository) {
+ suspend operator fun invoke(text: String, images: List = emptyList()) {
+ repository.sendMessage(text, images)
+ }
+}
diff --git a/android/core/ui/build.gradle.kts b/android/core/ui/build.gradle.kts
new file mode 100644
index 000000000..6c66f5c02
--- /dev/null
+++ b/android/core/ui/build.gradle.kts
@@ -0,0 +1,31 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.compose.compiler)
+}
+
+android {
+ namespace = "io.picoclaw.android.core.ui"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 28
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.ui)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.ui.tooling.preview)
+
+ debugImplementation(libs.compose.ui.tooling)
+}
diff --git a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Color.kt b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Color.kt
new file mode 100644
index 000000000..7686b0b89
--- /dev/null
+++ b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Color.kt
@@ -0,0 +1,17 @@
+package io.picoclaw.android.core.ui.theme
+
+import androidx.compose.ui.graphics.Color
+
+val Purple80 = Color(0xFFD0BCFF)
+val PurpleGrey80 = Color(0xFFCCC2DC)
+val Pink80 = Color(0xFFEFB8C8)
+
+val Purple40 = Color(0xFF6650a4)
+val PurpleGrey40 = Color(0xFF625b71)
+val Pink40 = Color(0xFF7D5260)
+
+val UserBubble = Color(0xFF2196F3)
+val AgentBubble = Color(0xFF424242)
+val ConnectedGreen = Color(0xFF4CAF50)
+val ReconnectingYellow = Color(0xFFFFC107)
+val DisconnectedRed = Color(0xFFF44336)
diff --git a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Theme.kt b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Theme.kt
new file mode 100644
index 000000000..cb4cbf198
--- /dev/null
+++ b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Theme.kt
@@ -0,0 +1,45 @@
+package io.picoclaw.android.core.ui.theme
+
+import android.os.Build
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+private val DarkColorScheme = darkColorScheme(
+ primary = Purple80,
+ secondary = PurpleGrey80,
+ tertiary = Pink80
+)
+
+private val LightColorScheme = lightColorScheme(
+ primary = Purple40,
+ secondary = PurpleGrey40,
+ tertiary = Pink40
+)
+
+@Composable
+fun PicoClawTheme(
+ darkTheme: Boolean = isSystemInDarkTheme(),
+ dynamicColor: Boolean = true,
+ content: @Composable () -> Unit
+) {
+ val colorScheme = when {
+ dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ val context = LocalContext.current
+ if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
+ }
+ darkTheme -> DarkColorScheme
+ else -> LightColorScheme
+ }
+
+ MaterialTheme(
+ colorScheme = colorScheme,
+ typography = Typography,
+ content = content
+ )
+}
diff --git a/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Type.kt b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Type.kt
new file mode 100644
index 000000000..1d024a221
--- /dev/null
+++ b/android/core/ui/src/main/java/io/picoclaw/android/core/ui/theme/Type.kt
@@ -0,0 +1,17 @@
+package io.picoclaw.android.core.ui.theme
+
+import androidx.compose.material3.Typography
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.sp
+
+val Typography = Typography(
+ bodyLarge = TextStyle(
+ fontFamily = FontFamily.Default,
+ fontWeight = FontWeight.Normal,
+ fontSize = 16.sp,
+ lineHeight = 24.sp,
+ letterSpacing = 0.5.sp
+ )
+)
diff --git a/android/feature/chat/build.gradle.kts b/android/feature/chat/build.gradle.kts
new file mode 100644
index 000000000..051642bc0
--- /dev/null
+++ b/android/feature/chat/build.gradle.kts
@@ -0,0 +1,43 @@
+plugins {
+ alias(libs.plugins.android.library)
+ alias(libs.plugins.compose.compiler)
+}
+
+android {
+ namespace = "io.picoclaw.android.feature.chat"
+ compileSdk = 35
+
+ defaultConfig {
+ minSdk = 28
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ implementation(project(":core:domain"))
+ implementation(project(":core:ui"))
+
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.ui)
+ implementation(libs.compose.ui.tooling.preview)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.icons.extended)
+ implementation(libs.activity.compose)
+ implementation(libs.lifecycle.runtime.compose)
+ implementation(libs.lifecycle.viewmodel.compose)
+
+ implementation(libs.koin.android)
+ implementation(libs.koin.compose)
+
+ implementation(libs.coroutines.android)
+
+ debugImplementation(libs.compose.ui.tooling)
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt
new file mode 100644
index 000000000..1909e39ab
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatEvent.kt
@@ -0,0 +1,13 @@
+package io.picoclaw.android.feature.chat
+
+import io.picoclaw.android.core.domain.model.ImageAttachment
+
+sealed interface ChatEvent {
+ data class OnInputChanged(val text: String) : ChatEvent
+ data object OnSendClick : ChatEvent
+ data class OnImageAdded(val image: ImageAttachment) : ChatEvent
+ data class OnImageRemoved(val index: Int) : ChatEvent
+ data object OnLoadMore : ChatEvent
+ data class OnError(val message: String) : ChatEvent
+ data object OnErrorDismissed : ChatEvent
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt
new file mode 100644
index 000000000..049c64d53
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatUiState.kt
@@ -0,0 +1,15 @@
+package io.picoclaw.android.feature.chat
+
+import io.picoclaw.android.core.domain.model.ChatMessage
+import io.picoclaw.android.core.domain.model.ConnectionState
+import io.picoclaw.android.core.domain.model.ImageAttachment
+
+data class ChatUiState(
+ val messages: List = emptyList(),
+ val connectionState: ConnectionState = ConnectionState.DISCONNECTED,
+ val inputText: String = "",
+ val pendingImages: List = emptyList(),
+ val isLoadingMore: Boolean = false,
+ val canLoadMore: Boolean = true,
+ val error: String? = null
+)
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
new file mode 100644
index 000000000..f2faf4515
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/ChatViewModel.kt
@@ -0,0 +1,85 @@
+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.LoadMoreMessagesUseCase
+import io.picoclaw.android.core.domain.usecase.ObserveConnectionUseCase
+import io.picoclaw.android.core.domain.usecase.ObserveMessagesUseCase
+import io.picoclaw.android.core.domain.usecase.SendMessageUseCase
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class ChatViewModel(
+ private val sendMessage: SendMessageUseCase,
+ private val observeMessages: ObserveMessagesUseCase,
+ private val observeConnection: ObserveConnectionUseCase,
+ private val loadMoreMessages: LoadMoreMessagesUseCase,
+ private val repository: ChatRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(ChatUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ repository.connect()
+
+ viewModelScope.launch {
+ observeMessages().collect { messages ->
+ _uiState.update { it.copy(messages = messages) }
+ }
+ }
+
+ viewModelScope.launch {
+ observeConnection().collect { state ->
+ _uiState.update { it.copy(connectionState = state) }
+ }
+ }
+ }
+
+ fun onEvent(event: ChatEvent) {
+ when (event) {
+ is ChatEvent.OnInputChanged -> {
+ _uiState.update { it.copy(inputText = event.text) }
+ }
+ is ChatEvent.OnSendClick -> {
+ val state = _uiState.value
+ val text = state.inputText.trim()
+ if (text.isEmpty() && state.pendingImages.isEmpty()) return
+ _uiState.update { it.copy(inputText = "", pendingImages = emptyList()) }
+ viewModelScope.launch {
+ try {
+ sendMessage(text, state.pendingImages)
+ } catch (e: Exception) {
+ _uiState.update { it.copy(error = e.message) }
+ }
+ }
+ }
+ is ChatEvent.OnImageAdded -> {
+ _uiState.update { it.copy(pendingImages = it.pendingImages + event.image) }
+ }
+ is ChatEvent.OnImageRemoved -> {
+ _uiState.update {
+ it.copy(pendingImages = it.pendingImages.filterIndexed { i, _ -> i != event.index })
+ }
+ }
+ is ChatEvent.OnLoadMore -> {
+ loadMoreMessages()
+ }
+ is ChatEvent.OnError -> {
+ _uiState.update { it.copy(error = event.message) }
+ }
+ is ChatEvent.OnErrorDismissed -> {
+ _uiState.update { it.copy(error = null) }
+ }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ repository.disconnect()
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ConnectionBanner.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ConnectionBanner.kt
new file mode 100644
index 000000000..9dad83930
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ConnectionBanner.kt
@@ -0,0 +1,45 @@
+package io.picoclaw.android.feature.chat.component
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import io.picoclaw.android.core.domain.model.ConnectionState
+import io.picoclaw.android.core.ui.theme.DisconnectedRed
+import io.picoclaw.android.core.ui.theme.ReconnectingYellow
+
+@Composable
+fun ConnectionBanner(
+ connectionState: ConnectionState,
+ modifier: Modifier = Modifier
+) {
+ AnimatedVisibility(visible = connectionState != ConnectionState.CONNECTED) {
+ val (color, text) = when (connectionState) {
+ ConnectionState.CONNECTING -> ReconnectingYellow to "Connecting..."
+ ConnectionState.RECONNECTING -> ReconnectingYellow to "Reconnecting..."
+ ConnectionState.DISCONNECTED -> DisconnectedRed to "Disconnected"
+ ConnectionState.CONNECTED -> Color.Transparent to ""
+ }
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .background(color)
+ .padding(vertical = 4.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = text,
+ color = Color.White,
+ fontSize = 12.sp
+ )
+ }
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt
new file mode 100644
index 000000000..f15bc13e3
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/ImagePreview.kt
@@ -0,0 +1,74 @@
+package io.picoclaw.android.feature.chat.component
+
+import android.graphics.BitmapFactory
+import android.util.Base64
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import io.picoclaw.android.core.domain.model.ImageAttachment
+
+@Composable
+fun ImagePreviewRow(
+ images: List,
+ onRemove: (Int) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ if (images.isEmpty()) return
+
+ LazyRow(
+ modifier = modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ itemsIndexed(images) { index, attachment ->
+ Box {
+ val bitmap = remember(attachment.base64) {
+ try {
+ val bytes = Base64.decode(attachment.base64, Base64.DEFAULT)
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
+ } catch (_: Exception) {
+ null
+ }
+ }
+ bitmap?.let {
+ Image(
+ bitmap = it,
+ contentDescription = null,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(RoundedCornerShape(8.dp)),
+ contentScale = ContentScale.Crop
+ )
+ }
+ IconButton(
+ onClick = { onRemove(index) },
+ modifier = Modifier
+ .size(20.dp)
+ .align(Alignment.TopEnd)
+ ) {
+ Icon(
+ Icons.Default.Close,
+ contentDescription = "Remove",
+ modifier = Modifier.size(14.dp)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt
new file mode 100644
index 000000000..a0ff34171
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageBubble.kt
@@ -0,0 +1,85 @@
+package io.picoclaw.android.feature.chat.component
+
+import android.graphics.BitmapFactory
+import android.util.Base64
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.unit.dp
+import io.picoclaw.android.core.domain.model.ChatMessage
+import io.picoclaw.android.core.domain.model.MessageSender
+import io.picoclaw.android.core.ui.theme.AgentBubble
+import io.picoclaw.android.core.ui.theme.UserBubble
+
+@Composable
+fun MessageBubble(
+ message: ChatMessage,
+ modifier: Modifier = Modifier
+) {
+ val isUser = message.sender == MessageSender.USER
+ val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
+ val bubbleColor = if (isUser) UserBubble else AgentBubble
+ val shape = RoundedCornerShape(
+ topStart = 16.dp,
+ topEnd = 16.dp,
+ bottomStart = if (isUser) 16.dp else 4.dp,
+ bottomEnd = if (isUser) 4.dp else 16.dp
+ )
+
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 2.dp),
+ contentAlignment = alignment
+ ) {
+ Surface(
+ shape = shape,
+ color = bubbleColor,
+ modifier = Modifier.widthIn(max = 300.dp)
+ ) {
+ Column(modifier = Modifier.padding(12.dp)) {
+ message.images.forEach { base64 ->
+ val bitmap = remember(base64) {
+ try {
+ val bytes = Base64.decode(base64, Base64.DEFAULT)
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
+ } catch (_: Exception) {
+ null
+ }
+ }
+ bitmap?.let {
+ Image(
+ bitmap = it,
+ contentDescription = null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(bottom = 8.dp),
+ contentScale = ContentScale.FillWidth
+ )
+ }
+ }
+ if (message.content.isNotEmpty()) {
+ Text(
+ text = message.content,
+ color = Color.White,
+ style = MaterialTheme.typography.bodyLarge
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt
new file mode 100644
index 000000000..21e45eca1
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageInput.kt
@@ -0,0 +1,61 @@
+package io.picoclaw.android.feature.chat.component
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.CameraAlt
+import androidx.compose.material.icons.filled.Image
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+@Composable
+fun MessageInput(
+ text: String,
+ onTextChanged: (String) -> Unit,
+ onSendClick: () -> Unit,
+ onCameraClick: () -> Unit,
+ onGalleryClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterEnd
+ ) {
+ IconButton(onClick = onCameraClick) {
+ Icon(Icons.Default.CameraAlt, contentDescription = "Camera")
+ }
+ IconButton(onClick = onGalleryClick) {
+ Icon(Icons.Default.Image, contentDescription = "Gallery")
+ }
+ OutlinedTextField(
+ value = text,
+ onValueChange = onTextChanged,
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("Message...") },
+ maxLines = 4,
+ shape = MaterialTheme.shapes.extraLarge
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ FilledIconButton(
+ onClick = onSendClick,
+ shape = CircleShape
+ ) {
+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "Send")
+ }
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageList.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageList.kt
new file mode 100644
index 000000000..c076daa12
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/component/MessageList.kt
@@ -0,0 +1,50 @@
+package io.picoclaw.android.feature.chat.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.picoclaw.android.core.domain.model.ChatMessage
+
+@Composable
+fun MessageList(
+ messages: List,
+ listState: LazyListState,
+ isLoadingMore: Boolean,
+ modifier: Modifier = Modifier
+) {
+ LazyColumn(
+ state = listState,
+ reverseLayout = true,
+ modifier = modifier,
+ contentPadding = PaddingValues(vertical = 8.dp)
+ ) {
+ items(
+ items = messages.reversed(),
+ key = { it.id }
+ ) { message ->
+ MessageBubble(message = message)
+ }
+ if (isLoadingMore) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+ }
+ }
+ }
+}
diff --git a/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt
new file mode 100644
index 000000000..59c7adb2e
--- /dev/null
+++ b/android/feature/chat/src/main/java/io/picoclaw/android/feature/chat/screen/ChatScreen.kt
@@ -0,0 +1,79 @@
+package io.picoclaw.android.feature.chat.screen
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import io.picoclaw.android.feature.chat.ChatEvent
+import io.picoclaw.android.feature.chat.ChatViewModel
+import io.picoclaw.android.feature.chat.component.ConnectionBanner
+import io.picoclaw.android.feature.chat.component.ImagePreviewRow
+import io.picoclaw.android.feature.chat.component.MessageInput
+import io.picoclaw.android.feature.chat.component.MessageList
+import org.koin.androidx.compose.koinViewModel
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ChatScreen(
+ viewModel: ChatViewModel = koinViewModel()
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val listState = rememberLazyListState()
+
+ val shouldLoadMore by remember {
+ derivedStateOf {
+ val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
+ lastVisibleItem != null &&
+ lastVisibleItem.index >= listState.layoutInfo.totalItemsCount - 5
+ }
+ }
+
+ LaunchedEffect(shouldLoadMore) {
+ if (shouldLoadMore) viewModel.onEvent(ChatEvent.OnLoadMore)
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(title = { Text("PicoClaw") })
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ ) {
+ ConnectionBanner(connectionState = uiState.connectionState)
+
+ MessageList(
+ messages = uiState.messages,
+ listState = listState,
+ isLoadingMore = uiState.isLoadingMore,
+ modifier = Modifier.weight(1f)
+ )
+
+ ImagePreviewRow(
+ images = uiState.pendingImages,
+ onRemove = { viewModel.onEvent(ChatEvent.OnImageRemoved(it)) }
+ )
+
+ MessageInput(
+ text = uiState.inputText,
+ onTextChanged = { viewModel.onEvent(ChatEvent.OnInputChanged(it)) },
+ onSendClick = { viewModel.onEvent(ChatEvent.OnSendClick) },
+ onCameraClick = { /* TODO */ },
+ onGalleryClick = { /* TODO */ }
+ )
+ }
+ }
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 000000000..73fb9057f
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+kotlin.code.style=official
+org.gradle.parallel=true
+org.gradle.caching=true
diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml
new file mode 100644
index 000000000..993af4460
--- /dev/null
+++ b/android/gradle/libs.versions.toml
@@ -0,0 +1,56 @@
+[versions]
+agp = "8.7.3"
+kotlin = "2.1.0"
+ksp = "2.1.0-1.0.29"
+compose-bom = "2024.12.01"
+ktor = "3.0.3"
+koin = "4.0.2"
+serialization = "1.7.3"
+room = "2.6.1"
+lifecycle = "2.8.7"
+activity-compose = "1.9.3"
+core-ktx = "1.15.0"
+coroutines = "1.9.0"
+
+[libraries]
+# Compose
+compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
+compose-ui = { group = "androidx.compose.ui", name = "ui" }
+compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+compose-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+
+# AndroidX
+activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
+core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
+lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
+lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
+
+# Ktor
+ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
+ktor-client-websockets = { group = "io.ktor", name = "ktor-client-websockets", version.ref = "ktor" }
+ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
+ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
+
+# Serialization
+serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
+
+# Room
+room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
+room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+
+# Koin
+koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" }
+koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" }
+
+# Coroutines
+coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "agp" }
+android-library = { id = "com.android.library", version.ref = "agp" }
+compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
+ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..a4b76b953
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..e2847c820
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100644
index 000000000..3d0881b54
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,158 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced functionality shells and target
+# temporary focusing, currentunixfunctionality, if present.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-hierarchical paths.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld -- "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NonStop* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ ;;
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ ;;
+ esac
+fi
+
+# Collect all arguments for the java command, stracks the current directory, preserve the arguments.
+# shellcheck disable=SC2153
+case $GRADLE_USER_HOME in
+ '') GRADLE_USER_HOME_OPTION="" ;;
+ *) GRADLE_USER_HOME_OPTION="-Dgradle.user.home=$GRADLE_USER_HOME" ;;
+esac
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and are treated as combinator of argments
+# * $APP_HOME is guaranteed to be a normal path
+# * gradle-wrapper.jar should not contain special characters
+
+# shellcheck disable=SC2086
+exec "$JAVACMD" \
+ $DEFAULT_JVM_OPTS \
+ $JAVA_OPTS \
+ $GRADLE_OPTS \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ $GRADLE_USER_HOME_OPTION \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 000000000..f709586f1
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,91 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %OS%==Windows_NT endlocal
+
+:omega
+
+exit /b %ERRORLEVEL%
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts
new file mode 100644
index 000000000..7a8097661
--- /dev/null
+++ b/android/settings.gradle.kts
@@ -0,0 +1,20 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "PicoClaw"
+include(":app")
+include(":feature:chat")
+include(":core:domain")
+include(":core:data")
+include(":core:ui")