Browse Source

android: add shared avatar components and guard picker account identity

master
evgeny 1 week ago
parent
commit
73019cfd83
  1. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  2. 47
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/AvatarRepository.kt
  3. 50
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/Avatar.kt

2
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

47
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<String>()
private val pending = mutableMapOf<Long, Pending>()
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<ByteArray>)
@ -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)

50
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)
}
}
Loading…
Cancel
Save