From 73019cfd83ae6fd91dc640885c740a0fac690cef Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 6 Oct 2026 16:22:24 +0200 Subject: [PATCH] android: add shared avatar components and guard picker account identity --- .../main/java/com/utun/chat/MainActivity.kt | 2 + .../com/utun/chat/data/AvatarRepository.kt | 47 +++++++++++++++-- .../com/utun/chat/ui/components/Avatar.kt | 50 +++++++++++++++++++ 3 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 2256aa02..f140aba5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -516,6 +516,7 @@ class MainActivity : AppCompatActivity() { MicrophonePermission.requester = microphoneRequester RadioOverlayService.overlayPermissionRequester = overlayPermissionRequester super.onStart() + ChatApplication.instance.avatars.setForeground(true) RadioAudioEngine.instance().setForeground(true) if (intent.getBooleanExtra(MicrophonePermission.EXTRA_REQUEST, false)) { intent.removeExtra(MicrophonePermission.EXTRA_REQUEST) @@ -592,6 +593,7 @@ class MainActivity : AppCompatActivity() { } override fun onStop() { + ChatApplication.instance.avatars.setForeground(false) RadioAudioEngine.instance().setForeground(false) super.onStop() if (MicrophonePermission.requester === microphoneRequester) MicrophonePermission.requester = null diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt index dbd2b6ef..d0f37657 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt @@ -8,6 +8,7 @@ import android.util.LruCache import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import java.io.File @@ -48,6 +49,28 @@ class AvatarRepository(context: Context) { private val dirtyGroups = mutableSetOf() private val pending = mutableMapOf() private var defaultAvatar: String? = null + private val foreground = MutableStateFlow(false) + fun setForeground(active: Boolean) { foreground.value = active } + val accountId: Long get() = account + fun profileBitmap(): Bitmap? = if (defaultAvatar == "-") null else + defaultAvatar?.substringAfter('/')?.let { cache.get(it) } ?: userBitmap(null, account) + fun ensureProfile() { + if (account == 0L) start() + val file = defaultAvatar?.substringAfter('/') + if (file == null || !AvatarProtocol.validFile(file)) { ensureUser(null, account); return } + if (cache.get(file) != null || !loads.add(file)) return + val uid = account; val epoch = generation + scope.launch { + try { + val bitmap = withContext(Dispatchers.IO) { + decode(File(directory(uid), file).readBytes(), file.removeSuffix(".png"), false) + } + alive(epoch); cache.put(file, bitmap); changed() + } catch (e: CancellationException) { throw e } + catch (e: Exception) { log("ERROR", "Default avatar cache unavailable file=$file: ${e.message}") } + finally { if (generation == epoch) loads.remove(file) } + } + } private data class Pending(val type: Int, val group: Long, val server: Long, val owner: Long, val command: Int, val result: CompletableDeferred) @@ -229,6 +252,7 @@ class AvatarRepository(context: Context) { try { while (true) { alive(epoch) + foreground.first { it } try { val bitmap = transfers.withPermit { val root = directory(uid); val file = File(root, key) @@ -295,7 +319,7 @@ class AvatarRepository(context: Context) { } } - fun editGroup(group: String, uri: Uri?) = edit("group:$group") { epoch -> + fun editGroup(group: String, uri: Uri?, expectedAccount: Long) = edit("group:$group", expectedAccount) { epoch -> val bytes = if (uri == null) ByteArray(0) else prepare(uri, true) alive(epoch) require(editTokens["group:$group"] == epoch) { "group edit superseded" } @@ -304,7 +328,7 @@ class AvatarRepository(context: Context) { ensureGroup(group, true) } - fun editProfile(server: Long, uri: Uri?) = edit("profile") { epoch -> + fun editProfile(server: Long, uri: Uri?, expectedAccount: Long) = edit("profile", expectedAccount) { epoch -> val uid = account val bytes = if (uri == null) null else prepare(uri, false) alive(epoch) @@ -314,20 +338,32 @@ class AvatarRepository(context: Context) { withContext(Dispatchers.IO) { val root = directory(uid) if (!root.isDirectory && !root.mkdirs()) throw IOException("cannot create profile cache") - File(root, file).writeBytes(bytes) + val temp = File(root, ".profile-${ids.incrementAndGet()}") + try { + temp.writeBytes(bytes) + if (!temp.renameTo(File(root, file))) throw IOException("cannot publish profile cache") + } finally { if (temp.exists() && !temp.delete()) log("WARN", "Cannot remove profile temp=$temp") } } alive(epoch) defaultAvatar = "${AvatarProtocol.hex(server)}/$file" cache.put(file, withContext(Dispatchers.IO) { decode(bytes, file.removeSuffix(".png"), false) }) } else defaultAvatar = "-" alive(epoch) - require(prefs.edit().putString(AvatarProtocol.hex(uid), defaultAvatar).commit()) { "cannot persist avatar intent" } + val desired = defaultAvatar + require(withContext(Dispatchers.IO) { prefs.edit().putString(AvatarProtocol.hex(uid), desired).commit() }) { + "cannot persist avatar intent" + } + alive(epoch) changed() _members.value.keys.map { it.group }.distinct().forEach { replicate(it) } } - private fun edit(key: String, block: suspend (Long) -> Unit) { + private fun edit(key: String, expectedAccount: Long, block: suspend (Long) -> Unit) { if (account == 0L) start() + if (expectedAccount == 0L || expectedAccount != account) { + log("WARN", "Ignored stale picker result target=$key expected=${AvatarProtocol.hex(expectedAccount)} actual=${AvatarProtocol.hex(account)}") + return + } if (account == 0L || _edits.value[key] == AvatarEditState.WORKING) return val epoch = generation; editTokens[key] = epoch _edits.value += key to AvatarEditState.WORKING @@ -355,6 +391,7 @@ class AvatarRepository(context: Context) { while (defaultAvatar == desired) { try { alive(epoch) + foreground.first { it } transfers.withPermit { if (server != 0L) { val local = File(directory(uid), file) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt new file mode 100644 index 00000000..f1c624e7 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt @@ -0,0 +1,50 @@ +package com.utun.chat.ui.components + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +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 androidx.compose.ui.unit.dp +import com.utun.chat.ChatApplication + +@Composable +fun UserAvatar(group: String?, owner: Long, name: String, modifier: Modifier = Modifier, + size: Dp = 40.dp, profile: Boolean = false) { + val repository = ChatApplication.instance.avatars + val revision by repository.revision.collectAsState() + LaunchedEffect(group, owner, revision, profile) { + if (profile) repository.ensureProfile() else repository.ensureUser(group, owner) + } + AvatarImage(if (profile) repository.profileBitmap() else repository.userBitmap(group, owner), name, modifier, size) +} + +@Composable +fun GroupAvatar(group: String, name: String, modifier: Modifier = Modifier, size: Dp = 44.dp) { + val repository = ChatApplication.instance.avatars + val revision by repository.revision.collectAsState() + LaunchedEffect(group, revision) { repository.ensureGroup(group) } + AvatarImage(repository.groupBitmap(group), name, modifier, size) +} + +@Composable +private fun AvatarImage(bitmap: Bitmap?, name: String, modifier: Modifier, size: Dp) { + Box(modifier.size(size).clip(CircleShape).background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center) { + if (bitmap != null) Image(bitmap.asImageBitmap(), contentDescription = null, + modifier = Modifier.size(size), contentScale = ContentScale.Crop) + else Text(name.trim().split(Regex("\\s+")).filter { it.isNotEmpty() }.take(2) + .joinToString("") { String(Character.toChars(it.codePointAt(0))) }.uppercase().ifEmpty { "#" }, + color = MaterialTheme.colorScheme.onSecondaryContainer, style = MaterialTheme.typography.titleMedium) + } +}