android: add shared avatar components and guard picker account identity
This commit is contained in:
1 parent
22aa5d47a5
commit
73019cfd83
3 files changed
+94
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user