Skip to content

Commit 94ed27c

Browse files
committed
perf(dialog): gate picker playback until ready
1 parent 52dc0d3 commit 94ed27c

38 files changed

Lines changed: 3441 additions & 870 deletions

‎core/ui/src/main/java/org/monogram/core/ui/RememberEnsuredFile.kt‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import androidx.compose.runtime.getValue
77
import androidx.compose.runtime.mutableStateOf
88
import androidx.compose.runtime.remember
99
import androidx.compose.runtime.setValue
10-
import java.io.File
1110
import kotlinx.coroutines.delay
1211
import kotlinx.coroutines.flow.Flow
12+
import java.io.File
1313

1414
/** Reads a media cache generation at the point of use. */
1515
@Composable
@@ -44,9 +44,14 @@ fun rememberEnsuredFile(
4444
identity: Any?,
4545
resolve: () -> File?,
4646
ensure: suspend () -> File?,
47+
demand: Boolean = true,
4748
): File? {
4849
var file by remember(identity) { mutableStateOf(resolve()) }
49-
LaunchedEffect(identity, generation) {
50+
LaunchedEffect(identity, generation, demand) {
51+
if (!demand) {
52+
file = resolve()
53+
return@LaunchedEffect
54+
}
5055
repeat(3) { attempt ->
5156
val resolved = resolve()
5257
if (resolved != null) {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package org.monogram.core.ui.components
2+
3+
import kotlinx.coroutines.flow.MutableStateFlow
4+
import kotlinx.coroutines.flow.StateFlow
5+
import org.monogram.core.models.LastSeen
6+
import org.monogram.core.ui.components.PeerPresence.flow
7+
import java.util.concurrent.ConcurrentHashMap
8+
9+
data class PeerPresenceLease(val status: String?, val statusAt: Long?)
10+
11+
/**
12+
* Latest presence lease, per peer. Online heartbeats move the deadline without a new
13+
* list or dialog state, so only a composable that collects [flow] restarts.
14+
*/
15+
object PeerPresence {
16+
private val leases = ConcurrentHashMap<Long, MutableStateFlow<PeerPresenceLease?>>()
17+
18+
fun flow(peerId: Long): StateFlow<PeerPresenceLease?> =
19+
leases.getOrPut(peerId) { MutableStateFlow(null) }
20+
21+
fun record(peerId: Long, status: String?, statusAt: Long?) {
22+
val flow = leases.getOrPut(peerId) { MutableStateFlow(null) }
23+
val current = flow.value
24+
if (current != null &&
25+
!LastSeen.affectsUi(current.status, current.statusAt, status, statusAt)
26+
) {
27+
return
28+
}
29+
val next = PeerPresenceLease(status, statusAt)
30+
if (current != next) flow.value = next
31+
}
32+
}

‎feature/chats/src/main/java/org/monogram/feature/chats/ChatsExecutor.kt‎

Lines changed: 109 additions & 31 deletions
Large diffs are not rendered by default.

‎feature/chats/src/main/java/org/monogram/feature/chats/ui/ChatRows.kt‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import androidx.compose.animation.AnimatedContent
44
import androidx.compose.foundation.background
55
import androidx.compose.foundation.clickable
66
import androidx.compose.foundation.combinedClickable
7-
import androidx.compose.foundation.selection.toggleable
87
import androidx.compose.foundation.layout.Arrangement
98
import androidx.compose.foundation.layout.Box
109
import androidx.compose.foundation.layout.Column
@@ -17,6 +16,7 @@ import androidx.compose.foundation.layout.offset
1716
import androidx.compose.foundation.layout.padding
1817
import androidx.compose.foundation.layout.size
1918
import androidx.compose.foundation.layout.width
19+
import androidx.compose.foundation.selection.toggleable
2020
import androidx.compose.foundation.shape.CircleShape
2121
import androidx.compose.foundation.shape.RoundedCornerShape
2222
import androidx.compose.material.icons.Icons
@@ -34,15 +34,17 @@ import androidx.compose.material.icons.outlined.MusicNote
3434
import androidx.compose.material.icons.outlined.NotificationsOff
3535
import androidx.compose.material.icons.outlined.PushPin
3636
import androidx.compose.material.icons.outlined.Videocam
37-
import androidx.compose.material3.Icon
3837
import androidx.compose.material3.Checkbox
3938
import androidx.compose.material3.CheckboxDefaults
39+
import androidx.compose.material3.Icon
4040
import androidx.compose.material3.MaterialTheme
4141
import androidx.compose.material3.Surface
4242
import androidx.compose.material3.Text
4343
import androidx.compose.material3.contentColorFor
4444
import androidx.compose.runtime.Composable
4545
import androidx.compose.runtime.Immutable
46+
import androidx.compose.runtime.collectAsState
47+
import androidx.compose.runtime.getValue
4648
import androidx.compose.runtime.remember
4749
import androidx.compose.ui.Alignment
4850
import androidx.compose.ui.Modifier
@@ -56,11 +58,11 @@ import androidx.compose.ui.platform.LocalDensity
5658
import androidx.compose.ui.platform.LocalResources
5759
import androidx.compose.ui.res.pluralStringResource
5860
import androidx.compose.ui.res.stringResource
59-
import androidx.compose.ui.semantics.contentDescription
6061
import androidx.compose.ui.semantics.Role
6162
import androidx.compose.ui.semantics.clearAndSetSemantics
62-
import androidx.compose.ui.semantics.semantics
63+
import androidx.compose.ui.semantics.contentDescription
6364
import androidx.compose.ui.semantics.selected
65+
import androidx.compose.ui.semantics.semantics
6466
import androidx.compose.ui.text.style.TextOverflow
6567
import androidx.compose.ui.unit.dp
6668
import coil.compose.AsyncImage
@@ -75,7 +77,9 @@ import org.monogram.core.ui.components.ChatRowMetrics
7577
import org.monogram.core.ui.components.EmojiStatusMark
7678
import org.monogram.core.ui.components.OutgoingStatusMark
7779
import org.monogram.core.ui.components.PeerAvatar
80+
import org.monogram.core.ui.components.PeerPresence
7881
import org.monogram.core.ui.components.ServiceAvatar
82+
import org.monogram.core.ui.components.SponsorBadge
7983
import org.monogram.core.ui.components.TypingDots
8084
import org.monogram.core.ui.components.UnreadBadge
8185
import org.monogram.core.ui.components.UnreadCountRow
@@ -96,7 +100,6 @@ import org.monogram.feature.chats.splitPreviewSender
96100
import org.monogram.network.http.MediaPriority
97101
import org.monogram.network.http.MediaRepository
98102
import org.monogram.feature.chats.chatListPreviewText as formatPreviewText
99-
import org.monogram.core.ui.components.SponsorBadge
100103

101104
internal val ChatRowHeight = 72.dp
102105
internal val ChatRowAvatarSize = ChatRowMetrics.AvatarSize
@@ -350,8 +353,8 @@ internal fun ChatRow(
350353
},
351354
)
352355
}
353-
if (!savedMessages && isOnline(chat)) {
354-
OnlineDot(modifier = Modifier.align(Alignment.BottomEnd))
356+
if (!savedMessages) {
357+
ChatOnlineDot(chat, Modifier.align(Alignment.BottomEnd))
355358
}
356359
}
357360
Spacer(Modifier.width(ChatRowMetrics.AvatarGap))
@@ -784,10 +787,13 @@ private fun rememberChatAvatar(
784787
)
785788

786789
@Composable
787-
private fun isOnline(chat: Chat): Boolean {
788-
if (chat.isChannel || chat.isGroup) return false
789-
val now = rememberPeerStatusNow(chat.peerStatus, chat.peerStatusAt)
790-
return isPeerOnline(chat.peerStatus, chat.peerStatusAt, now)
790+
private fun ChatOnlineDot(chat: Chat, modifier: Modifier) {
791+
if (chat.isChannel || chat.isGroup) return
792+
val live by PeerPresence.flow(chat.id.value).collectAsState()
793+
val status = live?.status ?: chat.peerStatus
794+
val statusAt = live?.statusAt ?: chat.peerStatusAt
795+
val now = rememberPeerStatusNow(status, statusAt)
796+
if (isPeerOnline(status, statusAt, now)) OnlineDot(modifier)
791797
}
792798

793799
@Composable

‎feature/dialog/src/main/java/org/monogram/feature/dialog/DialogComponent.kt‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,16 @@ class DialogComponent(
173173
}
174174
}
175175

176-
fun onPickerDocumentsVisible(documentIds: List<Long>, visibleIds: Set<Long>) {
176+
fun onPickerDocumentsVisible(
177+
documentIds: List<Long>,
178+
visibleIds: Set<Long>,
179+
idle: Boolean,
180+
) {
177181
preloadScope.launch {
178182
val items = PickerMediaPreload.documents(documentIds).map { item ->
179183
item.copy(kind = pickerDocKind(mediaRepository?.cachedFile(item.cacheKey)))
180184
}
181-
pickerPreloader.onPlan(PickerMediaPreload.plan(items, visibleIds))
185+
pickerPreloader.onPlan(PickerMediaPreload.plan(items, visibleIds, idle = idle))
182186
}
183187
}
184188

‎feature/dialog/src/main/java/org/monogram/feature/dialog/PanelFrameCache.kt‎

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
package org.monogram.feature.dialog
22

3-
import java.io.ByteArrayInputStream
43
import java.io.ByteArrayOutputStream
54
import java.io.DataInputStream
65
import java.io.DataOutputStream
76
import java.io.File
87
import java.util.zip.Deflater
98
import java.util.zip.DeflaterOutputStream
10-
import java.util.zip.InflaterInputStream
9+
import java.util.zip.Inflater
1110
import kotlin.math.max
1211
import kotlin.math.roundToInt
1312

@@ -16,13 +15,19 @@ internal data class PanelFrameClip(
1615
val height: Int,
1716
val delayMs: Int,
1817
val frames: List<ByteArray>,
18+
val complete: Boolean = false,
19+
val sourceDurationMs: Long = 0L,
1920
)
2021

2122
internal object PanelFrameCache {
23+
internal const val PACKED_BYTE_CAP = 32 * 1024 * 1024
24+
internal const val INFLATED_BYTE_CAP = 512 * 512 * 4 * 90
25+
2226
private val MAGIC =
23-
byteArrayOf('P'.code.toByte(), 'F'.code.toByte(), '0'.code.toByte(), '1'.code.toByte())
27+
byteArrayOf('P'.code.toByte(), 'F'.code.toByte(), '0'.code.toByte(), '2'.code.toByte())
2428
private const val MAX_FRAMES = 90
2529
private const val MAX_SIDE = 512
30+
private const val HEADER_BYTES = 33L
2631
private const val PANEL_FRAME_BYTE_CAP = 32L * 1024 * 1024
2732

2833
fun file(dir: File, documentId: Long, bucket: PanelBucket, tag: PanelTag): File =
@@ -39,6 +44,8 @@ internal object PanelFrameCache {
3944
data.writeInt(clip.height)
4045
data.writeInt(clip.delayMs.coerceAtLeast(1))
4146
data.writeInt(clip.frames.size)
47+
data.writeBoolean(clip.complete)
48+
data.writeLong(clip.sourceDurationMs.coerceAtLeast(0L))
4249
val packed = ByteArrayOutputStream()
4350
DeflaterOutputStream(packed, Deflater(Deflater.BEST_SPEED)).use { deflater ->
4451
clip.frames.forEach { frame -> deflater.write(frame) }
@@ -62,8 +69,15 @@ internal object PanelFrameCache {
6269
)
6370
}
6471

65-
fun read(source: File): PanelFrameClip? {
66-
if (!source.isFile || source.length() < 24L) return null
72+
fun read(source: File): PanelFrameClip? = readBounded(source, requestedSide = null)
73+
74+
fun read(source: File, requestedSide: Int): PanelFrameClip? {
75+
if (requestedSide <= 0) return null
76+
return readBounded(source, requestedSide)
77+
}
78+
79+
private fun readBounded(source: File, requestedSide: Int?): PanelFrameClip? {
80+
if (!source.isFile || source.length() < HEADER_BYTES) return null
6781
return runCatching {
6882
source.inputStream().buffered().use { stream ->
6983
val data = DataInputStream(stream)
@@ -74,34 +88,57 @@ internal object PanelFrameCache {
7488
val height = data.readInt()
7589
val delay = data.readInt()
7690
val count = data.readInt()
91+
val complete = data.readBoolean()
92+
val sourceDurationMs = data.readLong()
7793
val packedSize = data.readInt()
94+
if (sourceDurationMs < 0L) return null
7895
if (!validShape(width, height, count, packedSize)) return null
96+
if (requestedSide != null && max(width, height) < requestedSide) return null
97+
val inflated = width.toLong() * height.toLong() * 4L * count.toLong()
98+
if (inflated <= 0L || inflated > INFLATED_BYTE_CAP) return null
99+
if (packedSize.toLong() > source.length() - HEADER_BYTES) return null
79100
val packed = ByteArray(packedSize)
80101
data.readFully(packed)
81-
val frameBytes = width * height * 4
82-
val raw = InflaterInputStream(ByteArrayInputStream(packed)).use { it.readBytes() }
83-
if (raw.size != frameBytes * count) return null
102+
val raw = ByteArray(inflated.toInt())
103+
val inflater = Inflater()
104+
try {
105+
inflater.setInput(packed)
106+
var offset = 0
107+
while (offset < raw.size) {
108+
val read = inflater.inflate(raw, offset, raw.size - offset)
109+
if (read <= 0) return null
110+
offset += read
111+
}
112+
if (!inflater.finished()) {
113+
val extra = inflater.inflate(ByteArray(1))
114+
if (extra != 0 || !inflater.finished()) return null
115+
}
116+
} finally {
117+
inflater.end()
118+
}
119+
val frameBytes = (inflated / count).toInt()
84120
val frames = ArrayList<ByteArray>(count)
85121
for (index in 0 until count) {
86122
val start = index * frameBytes
87123
frames += raw.copyOfRange(start, start + frameBytes)
88124
}
89-
PanelFrameClip(width, height, delay, frames)
125+
PanelFrameClip(width, height, delay, frames, complete, sourceDurationMs)
90126
}
91127
}.getOrNull()
92128
}
93129

94130
private fun storeable(clip: PanelFrameClip): Boolean {
131+
if (clip.sourceDurationMs < 0L) return false
95132
if (!validShape(clip.width, clip.height, clip.frames.size, packedSize = 1)) return false
96133
val frameBytes = clip.width * clip.height * 4
97134
return clip.frames.all { it.size == frameBytes }
98135
}
99136

100137
private fun validShape(width: Int, height: Int, count: Int, packedSize: Int): Boolean {
101138
if (width !in 1..MAX_SIDE || height !in 1..MAX_SIDE) return false
102-
if (count !in 1..MAX_FRAMES || packedSize <= 0) return false
103-
val area = width.toLong() * height.toLong()
104-
return area <= MAX_SIDE.toLong() * MAX_SIDE.toLong()
139+
if (count !in 1..MAX_FRAMES || packedSize <= 0 || packedSize > PACKED_BYTE_CAP) return false
140+
val inflated = width.toLong() * height.toLong() * 4L * count.toLong()
141+
return inflated in 1..INFLATED_BYTE_CAP.toLong()
105142
}
106143
}
107144

0 commit comments

Comments
 (0)