diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ad0bda3..ff0c37b 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -29,6 +29,10 @@
without it fails with a RuntimeException carrying no useful message.
-->
+
+
+
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt
index f88085d..cab08d2 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallManager.kt
@@ -5,6 +5,14 @@ import io.flamenet.e2e.Content
import org.json.JSONObject
import org.webrtc.AudioSource
import org.webrtc.AudioTrack
+import org.webrtc.Camera2Enumerator
+import org.webrtc.DefaultVideoDecoderFactory
+import org.webrtc.DefaultVideoEncoderFactory
+import org.webrtc.EglBase
+import org.webrtc.SurfaceTextureHelper
+import org.webrtc.VideoCapturer
+import org.webrtc.VideoSource
+import org.webrtc.VideoTrack
import org.webrtc.DataChannel
import org.webrtc.IceCandidate
import org.webrtc.MediaConstraints
@@ -46,7 +54,8 @@ class CallManager(
enum class State { IDLE, DIALLING, RINGING, CONNECTING, ACTIVE, ENDED }
- data class Active(val callId: String, val peerId: Long, val incoming: Boolean)
+ data class Active(val callId: String, val peerId: Long, val incoming: Boolean,
+ val video: Boolean = false)
var current: Active? = null
private set
@@ -62,6 +71,20 @@ class CallManager(
private var audioSource: AudioSource? = null
private var audioTrack: AudioTrack? = null
+ // -- Video (§6.8v) -------------------------------------------------------
+ // One EGL context for the factory's codecs, the capturer and every
+ // renderer — WebRTC textures only travel between things that share it.
+ val eglBase: EglBase by lazy { EglBase.create() }
+ private var videoCapturer: VideoCapturer? = null
+ private var videoHelper: SurfaceTextureHelper? = null
+ private var videoSource: VideoSource? = null
+ var localVideoTrack: VideoTrack? = null
+ private set
+ var remoteVideoTrack: VideoTrack? = null
+ private set
+ /** Fired on the main thread when a remote video track appears or goes. */
+ var onVideoTracksChanged: (() -> Unit)? = null
+
private val main = android.os.Handler(android.os.Looper.getMainLooper())
/**
@@ -147,11 +170,18 @@ class CallManager(
.createInitializationOptions()
)
return PeerConnectionFactory.builder()
+ // Video codecs even for a voice call: the factory is built once
+ // per process, and a voice-only factory would make the first
+ // video call render nothing while looking connected.
+ .setVideoEncoderFactory(
+ DefaultVideoEncoderFactory(eglBase.eglBaseContext, true, true))
+ .setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
.createPeerConnectionFactory()
.also { factory = it }
}
- private fun open(turn: JSONObject?, peerId: Long, callId: String): PeerConnection? {
+ private fun open(turn: JSONObject?, peerId: Long, callId: String,
+ video: Boolean): PeerConnection? {
val config = PeerConnection.RTCConfiguration(iceServers(turn)).apply {
// ALL rather than RELAY: direct paths are tried, and TURN is the
// fallback. RELAY would force every call through the server, which
@@ -203,7 +233,17 @@ class CallManager(
override fun onRemoveStream(stream: MediaStream?) = Unit
override fun onDataChannel(channel: DataChannel?) = Unit
override fun onRenegotiationNeeded() = Unit
- override fun onAddTrack(receiver: RtpReceiver?, streams: Array?) = Unit
+ override fun onAddTrack(receiver: RtpReceiver?, streams: Array?) {
+ // The other side's camera. Same JNI rules as above: store the
+ // reference, hop to main, and let the UI attach a renderer.
+ runCatching {
+ val track = receiver?.track()
+ if (track is VideoTrack) {
+ remoteVideoTrack = track
+ main.post { runCatching { onVideoTracksChanged?.invoke() } }
+ }
+ }
+ }
}
val pc = ensureFactory().createPeerConnection(config, observer) ?: return null
@@ -212,13 +252,44 @@ class CallManager(
pc.addTrack(track, listOf("fnstream"))
audioSource = source
audioTrack = track
+ if (video) {
+ // ⚠️ Failure here downgrades to voice rather than killing the
+ // call — an emulator without a camera, or a denied permission
+ // mid-flow, should still connect and say something.
+ runCatching {
+ val cams = Camera2Enumerator(context)
+ val name = cams.deviceNames.firstOrNull { cams.isFrontFacing(it) }
+ ?: cams.deviceNames.firstOrNull()
+ if (name != null) {
+ val cap = cams.createCapturer(name, null)
+ val helper = SurfaceTextureHelper.create("fncap", eglBase.eglBaseContext)
+ val vs = ensureFactory().createVideoSource(cap.isScreencast)
+ cap.initialize(helper, context, vs.capturerObserver)
+ cap.startCapture(960, 540, 24)
+ val vt = ensureFactory().createVideoTrack("fnvideo", vs)
+ val sender = pc.addTrack(vt, listOf("fnstream"))
+ // A cap, so video through the shared TURN cannot saturate
+ // the relay host. ~800 kbps is comfortable 540p.
+ runCatching {
+ val params = sender.parameters
+ params.encodings.forEach { it.maxBitrateBps = 800_000 }
+ sender.parameters = params
+ }
+ videoCapturer = cap
+ videoHelper = helper
+ videoSource = vs
+ localVideoTrack = vt
+ main.post { runCatching { onVideoTracksChanged?.invoke() } }
+ }
+ }
+ }
connection = pc
- current = Active(callId, peerId, incoming = false)
+ current = Active(callId, peerId, incoming = false, video = video)
return pc
}
/** Place a call. The offer is gathered, then sent as one encrypted envelope. */
- fun call(peerId: Long, turn: JSONObject?) {
+ fun call(peerId: Long, turn: JSONObject?, video: Boolean = false) {
if (state != State.IDLE && state != State.ENDED) return
val callId = UUID.randomUUID().toString()
// 🔴 `current` BEFORE the state change. The state setter notifies the
@@ -226,9 +297,9 @@ class CallManager(
// is a call to draw — DIALLING with a null `current` cleared the
// banner the UI had just put up, and the caller's screen showed
// nothing until a second tap redrew it (while this guard ate the tap).
- current = Active(callId, peerId, incoming = false)
+ current = Active(callId, peerId, incoming = false, video = video)
state = State.DIALLING
- val pc = open(turn, peerId, callId) ?: run {
+ val pc = open(turn, peerId, callId, video) ?: run {
current = null
state = State.IDLE
return
@@ -241,7 +312,8 @@ class CallManager(
// arrives, so the caller's screen says what is actually true.
main.post {
sendWhenGathered { full ->
- signal(peerId, Content.CallSignal(Content.CallSignal.OFFER, callId, full))
+ signal(peerId, Content.CallSignal(
+ Content.CallSignal.OFFER, callId, full, video = video))
}
}
}
@@ -288,7 +360,8 @@ class CallManager(
return
}
pendingOffer = fromUser to incoming
- current = Active(incoming.callId, fromUser, incoming = true)
+ current = Active(incoming.callId, fromUser, incoming = true,
+ video = incoming.video)
state = State.RINGING
}
Content.CallSignal.ANSWER -> {
@@ -317,11 +390,11 @@ class CallManager(
pendingOffer = null
val sdp = offer.sdp ?: return
state = State.CONNECTING
- val pc = open(turn, fromUser, offer.callId) ?: run {
+ val pc = open(turn, fromUser, offer.callId, offer.video) ?: run {
state = State.IDLE
return
}
- current = Active(offer.callId, fromUser, incoming = true)
+ current = Active(offer.callId, fromUser, incoming = true, video = offer.video)
pc.setRemoteDescription(
SimpleSdpObserver(),
SessionDescription(SessionDescription.Type.OFFER, sdp),
@@ -334,7 +407,8 @@ class CallManager(
main.post {
sendWhenGathered { full ->
signal(fromUser, Content.CallSignal(
- Content.CallSignal.ANSWER, offer.callId, full))
+ Content.CallSignal.ANSWER, offer.callId, full,
+ video = offer.video))
}
}
}
@@ -400,6 +474,18 @@ class CallManager(
audioTrack = null
audioSource?.let { runCatching { it.dispose() } }
audioSource = null
+ // The camera, released in capture order. Same rule as the mic: the
+ // capturer holds the hardware, not the connection, and a missed
+ // dispose leaves the camera indicator on with no call attached.
+ videoCapturer?.let { runCatching { it.stopCapture() }; runCatching { it.dispose() } }
+ videoCapturer = null
+ videoHelper?.let { runCatching { it.dispose() } }
+ videoHelper = null
+ videoSource?.let { runCatching { it.dispose() } }
+ videoSource = null
+ localVideoTrack = null
+ remoteVideoTrack = null
+ main.post { runCatching { onVideoTracksChanged?.invoke() } }
pendingOffer = null
// 🔴 ENDED fires while `current` still says who the call was with —
// the observer files the call log entry from it, and nulling `current`
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt
index 487210d..35c976e 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallScreen.kt
@@ -18,6 +18,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -217,14 +218,149 @@ fun LiveCallScreen(model: MessengerViewModel, phase: MessengerViewModel.Phase.Si
)
}
- CallScreenBody(
- peerName = call.peerName,
- status = status,
- actions = actions,
- warning = if (call.relayless && call.state != CallManager.State.ACTIVE) {
- "This server has no call relay, so the call only connects if a " +
- "direct path exists between the two networks."
- } else null,
- )
+ // A video call with anything to show renders surfaces instead of the
+ // ring; the same action row overlays the bottom. Voice calls and video
+ // calls still connecting keep the kit's ring screen.
+ val manager = model.activeCallManager()
+ val showVideo = call.video && call.state == CallManager.State.ACTIVE && manager != null
+ if (showVideo) {
+ VideoCallBody(
+ manager = manager!!,
+ videoTick = call.videoTick,
+ peerName = call.peerName,
+ actions = actions,
+ )
+ } else {
+ CallScreenBody(
+ peerName = call.peerName,
+ status = status,
+ actions = actions,
+ warning = if (call.relayless && call.state != CallManager.State.ACTIVE) {
+ "This server has no call relay, so the call only connects if a " +
+ "direct path exists between the two networks."
+ } else null,
+ )
+ }
return true
}
+
+/**
+ * The video layout: their camera full-screen, yours in a corner card, the
+ * usual actions along the bottom — the kit's 08 Video Call artboard.
+ *
+ * ⚠️ `videoTick` exists to re-run this when tracks appear: the track objects
+ * live on the manager, not in Compose state, so nothing else would recompose.
+ */
+@Composable
+private fun VideoCallBody(
+ manager: CallManager,
+ videoTick: Int,
+ peerName: String,
+ actions: List,
+) {
+ Box(modifier = Modifier.fillMaxSize().background(Color.Black).testTag("video-call")) {
+ val remote = manager.remoteVideoTrack
+ val local = manager.localVideoTrack
+ if (remote != null) {
+ key(remote, videoTick) {
+ VideoSurface(manager, remote, mirror = false,
+ modifier = Modifier.fillMaxSize())
+ }
+ } else {
+ Text(
+ "Waiting for their camera…",
+ color = Color.White,
+ modifier = Modifier.align(Alignment.Center),
+ )
+ }
+ if (local != null) {
+ key(local, videoTick) {
+ VideoSurface(
+ manager, local, mirror = true,
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(Kit.margin)
+ .size(width = 108.dp, height = 144.dp)
+ .clip(androidx.compose.foundation.shape.RoundedCornerShape(12.dp)),
+ )
+ }
+ }
+ Column(
+ modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = Space.xl),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Text(peerName, color = Color.White, fontSize = 16.sp,
+ fontWeight = FontWeight.Medium)
+ Spacer(Modifier.height(Space.md))
+ Row(horizontalArrangement = Arrangement.spacedBy(Space.lg)) {
+ actions.forEach { CallButton(it, diameter = if (it.isPrimary) 72.dp else 48.dp) }
+ }
+ }
+ }
+}
+
+/**
+ * One WebRTC renderer. Init and release travel with the composable — a
+ * SurfaceViewRenderer that outlives its sink leaks the EGL surface, and one
+ * released while still sinked crashes in native code.
+ */
+@Composable
+private fun VideoSurface(
+ manager: CallManager,
+ track: org.webrtc.VideoTrack,
+ mirror: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ androidx.compose.ui.viewinterop.AndroidView(
+ modifier = modifier,
+ factory = { ctx ->
+ org.webrtc.SurfaceViewRenderer(ctx).apply {
+ init(manager.eglBase.eglBaseContext, null)
+ setMirror(mirror)
+ setEnableHardwareScaler(true)
+ runCatching { track.addSink(this) }
+ }
+ },
+ onRelease = { view ->
+ runCatching { track.removeSink(view) }
+ runCatching { view.release() }
+ },
+ )
+}
+
+
+/**
+ * A "start a video call" action that asks for the camera first.
+ *
+ * Returns a function of the peer id. The permission launcher must be created
+ * during composition, so this cannot be a plain method on the model — and
+ * asking at the moment of the tap is the only time the question makes sense
+ * to the person being asked.
+ */
+@Composable
+fun rememberVideoCall(model: MessengerViewModel): (Long) -> Unit {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val pending = androidx.compose.runtime.remember {
+ androidx.compose.runtime.mutableStateOf(null)
+ }
+ val ask = androidx.activity.compose.rememberLauncherForActivityResult(
+ androidx.activity.result.contract.ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ val peer = pending.value
+ pending.value = null
+ // Denied still calls — as voice-grade video (their picture, no ours is
+ // wrong; the manager simply has no camera to add, so it degrades).
+ if (peer != null && granted) model.requestCall(peer, video = true)
+ }
+ return { peer ->
+ val has = androidx.core.content.ContextCompat.checkSelfPermission(
+ context, android.Manifest.permission.CAMERA
+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
+ if (has) {
+ model.requestCall(peer, video = true)
+ } else {
+ pending.value = peer
+ ask.launch(android.Manifest.permission.CAMERA)
+ }
+ }
+}
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt
index 6226183..b5cf665 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/CallsScreen.kt
@@ -49,6 +49,7 @@ fun ColumnScope.CallsScreen(
model: MessengerViewModel,
phase: MessengerViewModel.Phase.SignedIn,
) {
+ val videoCall = rememberVideoCall(model)
val showNewCall = androidx.compose.runtime.remember {
androidx.compose.runtime.mutableStateOf(false)
}
@@ -105,6 +106,19 @@ fun ColumnScope.CallsScreen(
)
Icon(KitIcon.Call, contentDescription = null,
tint = Palette.Accent, modifier = Modifier.size(20.dp))
+ Spacer(Modifier.width(Space.md))
+ Icon(
+ KitIcon.Video,
+ contentDescription = "Video call",
+ tint = Palette.Accent,
+ modifier = Modifier
+ .size(22.dp)
+ .clickable {
+ showNewCall.value = false
+ videoCall(buddy.userId)
+ }
+ .testTag("new-video-call-${buddy.userId}"),
+ )
}
}
}
@@ -199,7 +213,24 @@ fun ColumnScope.CallsScreen(
)
}
}
- // Ring them back, which is the only thing this row is for.
+ // Ring them back — voice, or camera-first.
+ Box(
+ modifier = Modifier
+ .size(Kit.icon + Space.md)
+ .clip(CircleShape)
+ .background(Palette.AccentSoft)
+ .clickable { videoCall(entry.peerId) }
+ .testTag("video-back-${entry.peerId}"),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ KitIcon.Video,
+ contentDescription = "Video call $name",
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(Kit.icon),
+ )
+ }
+ Spacer(Modifier.width(Space.sm))
Box(
modifier = Modifier
.size(Kit.icon + Space.md)
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt
index 2b0f5df..19ea61c 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/ChatScreens.kt
@@ -1293,6 +1293,13 @@ fun ColumnScope.ChatScreen(
// different key handling, and an enabled-looking button that cannot
// work is worse than an absent one.
if (chat.peerId != null) {
+ val videoCall = rememberVideoCall(model)
+ IconAction(
+ icon = KitIcon.Video,
+ label = "Video call",
+ tag = "start-video-call",
+ onClick = { videoCall(chat.peerId) },
+ )
// ⚠️ **The kit's bar has a video icon here and this does not**, and
// that is deliberate. Neither CallManager has any video path at
// all — no capturer, no video track, nothing — so the button would
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt
index 0baecc3..aa96def 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/MainActivity.kt
@@ -537,10 +537,11 @@ private fun BottomNav(
phase.calls.count { !it.connected && it.incoming }, "nav-calls")
NavButton(model, phase, MessengerViewModel.Phase.Screen.STORIES,
KitIcon.Stories, KitIcon.Stories, "Stories", 0, "nav-stories")
- NavButton(model, phase, MessengerViewModel.Phase.Screen.NOTIFICATIONS,
- KitIcon.Alerts, KitIcon.Alerts, "Alerts", unreadNotes, "nav-alerts")
+ // Alerts left the bar 2026-08-28 — five tabs plus it was crowded, and
+ // iOS never had one (its unread count badges Account). Same here now;
+ // the screen itself survives for the badge tap-through from Account.
NavButton(model, phase, MessengerViewModel.Phase.Screen.ACCOUNT,
- KitIcon.Account, KitIcon.Account, "Account", 0, "nav-account")
+ KitIcon.Account, KitIcon.Account, "Account", unreadNotes, "nav-account")
}
}
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
index d4474aa..398685c 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/MessengerViewModel.kt
@@ -159,6 +159,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
val state: CallManager.State,
val peerId: Long,
val peerName: String,
+ /** A camera call — the live screen renders video surfaces. */
+ val video: Boolean = false,
+ /** Bumped when video tracks appear/vanish so Compose re-reads them. */
+ val videoTick: Int = 0,
val incoming: Boolean,
val muted: Boolean = false,
/**
@@ -986,7 +990,7 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
peerId = active.peerId,
peerName = nameFor(phase, active.peerId),
incoming = active.incoming,
- )).copy(state = state),
+ )).copy(state = state, video = active.video),
)
}
}
@@ -1000,6 +1004,9 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
refreshCallLog()
}
}
+ manager.onVideoTracksChanged = {
+ update { it.copy(call = it.call?.copy(videoTick = it.call.videoTick + 1)) }
+ }
calls = manager
installCallSignalHandler()
return manager
@@ -1099,8 +1106,12 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
* find out afterwards. Asked once, not every time — a prompt people see
* repeatedly is a prompt they stop reading.
*/
- fun requestCall(peerId: Long) {
- if (callPrefs().getBoolean(CALL_PRIVACY_ACKNOWLEDGED, false)) placeCall(peerId)
+ /** Remembered across the privacy prompt, so "Understood, call" keeps it. */
+ private var pendingCallVideo = false
+
+ fun requestCall(peerId: Long, video: Boolean = false) {
+ pendingCallVideo = video
+ if (callPrefs().getBoolean(CALL_PRIVACY_ACKNOWLEDGED, false)) placeCall(peerId, video)
else update { it.copy(callPrivacyPrompt = peerId) }
}
@@ -1108,10 +1119,10 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
callPrefs().edit().putBoolean(CALL_PRIVACY_ACKNOWLEDGED, true).apply()
val peerId = signedIn()?.callPrivacyPrompt
update { it.copy(callPrivacyPrompt = null) }
- if (andCall && peerId != null) placeCall(peerId)
+ if (andCall && peerId != null) placeCall(peerId, pendingCallVideo)
}
- private fun placeCall(peerId: Long) = viewModelScope.launch {
+ private fun placeCall(peerId: Long, video: Boolean = false) = viewModelScope.launch {
callWasConnected = false
val current = session ?: return@launch
val manager = callManager()
@@ -1123,17 +1134,18 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
// and that banner is never shown. A call to somebody with no registered
// device looked identical to a call that rang.
android.util.Log.i("fnmsg-call",
- "placing call to $peerId, turn=${if (turn == null) "none" else "yes"}")
+ "placing call to $peerId, video=$video, turn=${if (turn == null) "none" else "yes"}")
update {
it.copy(call = Phase.CallState(
state = CallManager.State.DIALLING,
peerId = peerId,
peerName = nameFor(it, peerId),
incoming = false,
+ video = video,
relayless = turn == null,
))
}
- withContext(Dispatchers.IO) { manager.call(peerId, turn) }
+ withContext(Dispatchers.IO) { manager.call(peerId, turn, video) }
}
fun acceptCall() = viewModelScope.launch {
@@ -1152,6 +1164,13 @@ class MessengerViewModel(app: Application) : AndroidViewModel(app) {
update { it.copy(call = null) }
}
+ /**
+ * The live manager, for the call screen's video renderers only. Views
+ * otherwise take plain values; a video surface is the one thing that
+ * genuinely needs the track objects and the shared EGL context.
+ */
+ fun activeCallManager(): CallManager? = calls
+
fun setCallMuted(muted: Boolean) {
calls?.setMuted(muted)
update { it.copy(call = it.call?.copy(muted = muted)) }
diff --git a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt
index 20bcdc9..426dada 100644
--- a/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt
+++ b/app/src/main/kotlin/io/flamenet/messenger/app/StoriesFeed.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -173,12 +174,15 @@ private fun StoryCard(
Box(
modifier = Modifier
.fillMaxWidth()
- .aspectRatio(375f / 320f)
+ .height(240.dp)
.clip(RoundedCornerShape(Radius.small))
.background(Palette.SurfaceAlt),
contentAlignment = Alignment.Center,
) {
- AttachedImage(story.ref, model)
+ // 🔴 The card's own image, cropped to FILL the box — not the chat
+ // bubble's 260dp thumbnail, which floated small on a full-width
+ // backboard and read as a broken layout.
+ StoryImage(story.ref, model)
}
if (story.caption.isNotBlank()) {
@@ -290,3 +294,35 @@ fun ColumnScope.StoriesCards(
Spacer(Modifier.height(Space.xl))
}
}
+
+
+/**
+ * A story's image at card size: fills the box, cropped. The chat bubble's
+ * `AttachedImage` deliberately caps at 260dp and letterboxes — right for a
+ * bubble, wrong for the kit's full-width post card.
+ */
+@Composable
+private fun StoryImage(ref: io.flamenet.e2e.Attachment.Ref, model: MessengerViewModel) {
+ val bitmap = remember(ref.id) {
+ androidx.compose.runtime.mutableStateOf(null)
+ }
+ val failed = remember(ref.id) { androidx.compose.runtime.mutableStateOf(false) }
+ LaunchedEffect(ref.id) {
+ val bytes = model.attachmentBytes(ref)
+ if (bytes == null) { failed.value = true; return@LaunchedEffect }
+ bitmap.value = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ if (bitmap.value == null) failed.value = true
+ }
+ val loaded = bitmap.value
+ when {
+ loaded != null -> androidx.compose.foundation.Image(
+ bitmap = loaded.asImageBitmap(),
+ contentDescription = "Story image",
+ contentScale = androidx.compose.ui.layout.ContentScale.Crop,
+ modifier = Modifier.fillMaxSize(),
+ )
+ failed.value -> Text("Image unavailable", fontSize = 14.sp,
+ color = Palette.SecondaryText)
+ else -> Text("Loading…", fontSize = 14.sp, color = Palette.SecondaryText)
+ }
+}
diff --git a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt
index 948a518..1e1ba4e 100644
--- a/engine/src/main/kotlin/io/flamenet/e2e/Content.kt
+++ b/engine/src/main/kotlin/io/flamenet/e2e/Content.kt
@@ -108,6 +108,9 @@ data class Content(
val callId: String,
val sdp: String? = null,
val reason: String? = null,
+ /** Offer/answer: this call carries video. Absent on the wire = false,
+ * so voice-only clients interoperate unchanged. */
+ val video: Boolean = false,
) {
companion object {
const val OFFER = "call-offer"
@@ -235,6 +238,7 @@ data class Content(
val call = JSONObject().put("id", signal.callId)
signal.sdp?.let { call.put("sdp", it) }
signal.reason?.let { call.put("reason", it) }
+ if (signal.video) call.put("video", true)
return """{"fnc":1,"kind":${quote(signal.action)},"call":$call}"""
}
@@ -324,6 +328,7 @@ data class Content(
callId = call.optString("id"),
sdp = call.optString("sdp").takeIf { it.isNotEmpty() },
reason = call.optString("reason").takeIf { it.isNotEmpty() },
+ video = call.optBoolean("video", false),
))
}