diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt index d0747ee32b0..a051bb91c3b 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/CanvasController.kt @@ -44,6 +44,14 @@ class CanvasController { return (q * 100.0).toInt().coerceIn(1, 100) } + private fun Bitmap.scaleForMaxWidth(maxWidth: Int?): Bitmap { + if (maxWidth == null || maxWidth <= 0 || width <= maxWidth) { + return this + } + val scaledHeight = (height.toDouble() * (maxWidth.toDouble() / width.toDouble())).toInt().coerceAtLeast(1) + return scale(maxWidth, scaledHeight) + } + fun attach(webView: WebView) { this.webView = webView reload() @@ -148,13 +156,7 @@ class CanvasController { withContext(Dispatchers.Main) { val wv = webView ?: throw IllegalStateException("no webview") val bmp = wv.captureBitmap() - val scaled = - if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { - val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) - bmp.scale(maxWidth, h) - } else { - bmp - } + val scaled = bmp.scaleForMaxWidth(maxWidth) val out = ByteArrayOutputStream() scaled.compress(Bitmap.CompressFormat.PNG, 100, out) @@ -165,13 +167,7 @@ class CanvasController { withContext(Dispatchers.Main) { val wv = webView ?: throw IllegalStateException("no webview") val bmp = wv.captureBitmap() - val scaled = - if (maxWidth != null && maxWidth > 0 && bmp.width > maxWidth) { - val h = (bmp.height.toDouble() * (maxWidth.toDouble() / bmp.width.toDouble())).toInt().coerceAtLeast(1) - bmp.scale(maxWidth, h) - } else { - bmp - } + val scaled = bmp.scaleForMaxWidth(maxWidth) val out = ByteArrayOutputStream() val (compressFormat, compressQuality) = diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt index 6fb01a463ea..2f706b7a6b2 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/ContactsHandler.kt @@ -248,30 +248,37 @@ private object SystemContactsDataSource : ContactsDataSource { } private fun loadPhones(resolver: ContentResolver, contactId: Long): List { - val projection = arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER) - resolver.query( - ContactsContract.CommonDataKinds.Phone.CONTENT_URI, - projection, - "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID}=?", - arrayOf(contactId.toString()), - null, - ).use { cursor -> - if (cursor == null) return emptyList() - val out = LinkedHashSet() - while (cursor.moveToNext()) { - val value = cursor.getString(0)?.trim().orEmpty() - if (value.isNotEmpty()) out += value - } - return out.toList() - } + return queryContactValues( + resolver = resolver, + contentUri = ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + valueColumn = ContactsContract.CommonDataKinds.Phone.NUMBER, + contactIdColumn = ContactsContract.CommonDataKinds.Phone.CONTACT_ID, + contactId = contactId, + ) } private fun loadEmails(resolver: ContentResolver, contactId: Long): List { - val projection = arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS) + return queryContactValues( + resolver = resolver, + contentUri = ContactsContract.CommonDataKinds.Email.CONTENT_URI, + valueColumn = ContactsContract.CommonDataKinds.Email.ADDRESS, + contactIdColumn = ContactsContract.CommonDataKinds.Email.CONTACT_ID, + contactId = contactId, + ) + } + + private fun queryContactValues( + resolver: ContentResolver, + contentUri: android.net.Uri, + valueColumn: String, + contactIdColumn: String, + contactId: Long, + ): List { + val projection = arrayOf(valueColumn) resolver.query( - ContactsContract.CommonDataKinds.Email.CONTENT_URI, + contentUri, projection, - "${ContactsContract.CommonDataKinds.Email.CONTACT_ID}=?", + "$contactIdColumn=?", arrayOf(contactId.toString()), null, ).use { cursor -> diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt index 4a2ce7a9a78..30522b6d755 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/DeviceNotificationListenerService.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import android.service.notification.NotificationListenerService import android.service.notification.StatusBarNotification +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -33,6 +34,21 @@ data class DeviceNotificationEntry( val isClearable: Boolean, ) +internal fun DeviceNotificationEntry.toJsonObject(): JsonObject { + return buildJsonObject { + put("key", JsonPrimitive(key)) + put("packageName", JsonPrimitive(packageName)) + put("postTimeMs", JsonPrimitive(postTimeMs)) + put("isOngoing", JsonPrimitive(isOngoing)) + put("isClearable", JsonPrimitive(isClearable)) + title?.let { put("title", JsonPrimitive(it)) } + text?.let { put("text", JsonPrimitive(it)) } + subText?.let { put("subText", JsonPrimitive(it)) } + category?.let { put("category", JsonPrimitive(it)) } + channelId?.let { put("channelId", JsonPrimitive(it)) } + } +} + data class DeviceNotificationSnapshot( val enabled: Boolean, val connected: Boolean, diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt index 8e6552edfbb..36b89eb2ec8 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/InvokeDispatcher.kt @@ -10,7 +10,6 @@ import ai.openclaw.android.protocol.OpenClawDeviceCommand import ai.openclaw.android.protocol.OpenClawLocationCommand import ai.openclaw.android.protocol.OpenClawMotionCommand import ai.openclaw.android.protocol.OpenClawNotificationsCommand -import ai.openclaw.android.protocol.OpenClawPhotosCommand import ai.openclaw.android.protocol.OpenClawScreenCommand import ai.openclaw.android.protocol.OpenClawSmsCommand import ai.openclaw.android.protocol.OpenClawSystemCommand @@ -146,7 +145,9 @@ class InvokeDispatcher( OpenClawSystemCommand.Notify.rawValue -> systemHandler.handleSystemNotify(paramsJson) // Photos command - OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest(paramsJson) + ai.openclaw.android.protocol.OpenClawPhotosCommand.Latest.rawValue -> photosHandler.handlePhotosLatest( + paramsJson, + ) // Contacts command OpenClawContactsCommand.Search.rawValue -> contactsHandler.handleContactsSearch(paramsJson) diff --git a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt index 8195ab84847..755b20513b4 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/node/NotificationsHandler.kt @@ -131,20 +131,7 @@ class NotificationsHandler private constructor( put( "notifications", JsonArray( - snapshot.notifications.map { entry -> - buildJsonObject { - put("key", JsonPrimitive(entry.key)) - put("packageName", JsonPrimitive(entry.packageName)) - put("postTimeMs", JsonPrimitive(entry.postTimeMs)) - put("isOngoing", JsonPrimitive(entry.isOngoing)) - put("isClearable", JsonPrimitive(entry.isClearable)) - entry.title?.let { put("title", JsonPrimitive(it)) } - entry.text?.let { put("text", JsonPrimitive(it)) } - entry.subText?.let { put("subText", JsonPrimitive(it)) } - entry.category?.let { put("category", JsonPrimitive(it)) } - entry.channelId?.let { put("channelId", JsonPrimitive(it)) } - } - }, + snapshot.notifications.map { entry -> entry.toJsonObject() }, ), ) }.toString() diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt new file mode 100644 index 00000000000..c54b80b6e84 --- /dev/null +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/Base64ImageState.kt @@ -0,0 +1,42 @@ +package ai.openclaw.android.ui.chat + +import android.graphics.BitmapFactory +import android.util.Base64 +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +internal data class Base64ImageState( + val image: ImageBitmap?, + val failed: Boolean, +) + +@Composable +internal fun rememberBase64ImageState(base64: String): Base64ImageState { + var image by remember(base64) { mutableStateOf(null) } + var failed by remember(base64) { mutableStateOf(false) } + + LaunchedEffect(base64) { + failed = false + image = + withContext(Dispatchers.Default) { + try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null + bitmap.asImageBitmap() + } catch (_: Throwable) { + null + } + } + if (image == null) failed = true + } + + return Base64ImageState(image = image, failed = failed) +} diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt index e121212529a..6b5fd6d8dbd 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMarkdown.kt @@ -1,7 +1,5 @@ package ai.openclaw.android.ui.chat -import android.graphics.BitmapFactory -import android.util.Base64 import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -20,15 +18,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue 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.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -47,8 +40,6 @@ import ai.openclaw.android.ui.mobileCaption1 import ai.openclaw.android.ui.mobileCodeBg import ai.openclaw.android.ui.mobileCodeText import ai.openclaw.android.ui.mobileTextSecondary -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import org.commonmark.Extension import org.commonmark.ext.autolink.AutolinkExtension import org.commonmark.ext.gfm.strikethrough.Strikethrough @@ -555,23 +546,8 @@ private data class ParsedDataImage( @Composable private fun InlineBase64Image(base64: String, mimeType: String?) { - var image by remember(base64) { mutableStateOf(null) } - var failed by remember(base64) { mutableStateOf(false) } - - LaunchedEffect(base64) { - failed = false - image = - withContext(Dispatchers.Default) { - try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null - bitmap.asImageBitmap() - } catch (_: Throwable) { - null - } - } - if (image == null) failed = true - } + val imageState = rememberBase64ImageState(base64) + val image = imageState.image if (image != null) { Image( @@ -580,7 +556,7 @@ private fun InlineBase64Image(base64: String, mimeType: String?) { contentScale = ContentScale.Fit, modifier = Modifier.fillMaxWidth(), ) - } else if (failed) { + } else if (imageState.failed) { Text( text = "Image unavailable", modifier = Modifier.padding(vertical = 2.dp), diff --git a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt index 3f4250c3dbb..70ecb33f113 100644 --- a/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt +++ b/apps/android/app/src/main/java/ai/openclaw/android/ui/chat/ChatMessageViews.kt @@ -1,7 +1,5 @@ package ai.openclaw.android.ui.chat -import android.graphics.BitmapFactory -import android.util.Base64 import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement @@ -16,16 +14,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily @@ -51,8 +43,6 @@ import ai.openclaw.android.ui.mobileTextSecondary import ai.openclaw.android.ui.mobileWarning import ai.openclaw.android.ui.mobileWarningSoft import java.util.Locale -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext private data class ChatBubbleStyle( val alignEnd: Boolean, @@ -241,23 +231,8 @@ private fun roleLabel(role: String): String { @Composable private fun ChatBase64Image(base64: String, mimeType: String?) { - var image by remember(base64) { mutableStateOf(null) } - var failed by remember(base64) { mutableStateOf(false) } - - LaunchedEffect(base64) { - failed = false - image = - withContext(Dispatchers.Default) { - try { - val bytes = Base64.decode(base64, Base64.DEFAULT) - val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@withContext null - bitmap.asImageBitmap() - } catch (_: Throwable) { - null - } - } - if (image == null) failed = true - } + val imageState = rememberBase64ImageState(base64) + val image = imageState.image if (image != null) { Surface( @@ -273,7 +248,7 @@ private fun ChatBase64Image(base64: String, mimeType: String?) { modifier = Modifier.fillMaxWidth(), ) } - } else if (failed) { + } else if (imageState.failed) { Text("Unsupported attachment", style = mobileCaption1, color = mobileTextSecondary) } }