Give sharing a bridge a way in

`shareBridge` shipped in the last change with nothing calling it. The
receiving half was complete — an offer arrives, is shown, is accepted by
a button — and the sending half existed only as a method. Half a feature,
and the half that was missing is the one the whole argument rests on: a
bridge nobody can hand out is a bridge nobody has.

The Account screen now lists contacts under the bridge, with a Send
beside each, and only when a bridge is actually configured. Sharing from
a screen with nothing set would have posted an empty line onto somebody
else's settings screen.

⚠️ The button says "Sent" afterwards but does not disable. An operator
rotates a bridge and the same person needs the new line; a control that
never re-arms would make that impossible from here. And "Sent" is not
persisted — whether somebody still has a working bridge is not something
this phone can know, so it is a confirmation for this sitting rather than
a record.

The copy says who learns what, because neither party is nothing: whoever
runs the bridge sees one more person connecting, and the contact learns
you use it.

1175 tests, 0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-06 20:37:57 -04:00
co-authored by Claude Opus 5
parent 4d915a237d
commit 9a20c06eda
2 changed files with 56 additions and 2 deletions
@@ -805,7 +805,7 @@ fun ColumnScope.AccountScreen(
// from it — filing it as privacy would make the claim the section itself
// is careful not to make.
SectionBand()
SettingsSection { NetworkSection(model) }
SettingsSection { NetworkSection(model, phase.buddies) }
SectionBand()
SettingsSection { ParentalControlsSection() }
@@ -2,6 +2,8 @@ package io.flamenet.messenger.app
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
@@ -42,13 +44,20 @@ import io.flamenet.obfs4.Obfs4Stats
* bridge then proxies to the relay, which sees exactly what it saw before.
*/
@Composable
fun ColumnScope.NetworkSection(model: MessengerViewModel? = null) {
fun ColumnScope.NetworkSection(
model: MessengerViewModel? = null,
buddies: List<io.flamenet.client.MessengerApi.Buddy> = emptyList(),
) {
val context = LocalContext.current
val settings = remember { Obfs4Settings(context) }
var draft by remember { mutableStateOf(settings.bridgeLine ?: "") }
var stored by remember { mutableStateOf(settings.bridgeLine) }
// Bumped on save/remove so the live status below re-reads.
var tick by remember { mutableIntStateOf(0) }
// Who has been handed the line this sitting. A confirmation, not a record:
// it is not persisted, because whether somebody still has a working bridge
// is not something this phone can know.
var sharedWith by remember { mutableStateOf(setOf<Long>()) }
@Suppress("UNUSED_EXPRESSION") tick
@@ -209,6 +218,51 @@ fun ColumnScope.NetworkSection(model: MessengerViewModel? = null) {
)
}
// 🔴 **The other half of receiving one.** A bridge is only useful while
// whoever wants to block it does not have the line, and every public way of
// handing one out is somewhere a censor can go and collect them. Passing it
// to somebody you already talk to is the one route that does not scale for
// them: enumerating bridges becomes infiltrating friendships one at a time.
//
// ⚠️ Only when a bridge is actually configured. "Share" on a screen with
// nothing set would send an empty line and put an un-actionable row on
// somebody else's settings screen.
if (stored != null && buddies.isNotEmpty()) {
Spacer(Modifier.height(Space.md))
Text("Share it with", fontWeight = FontWeight.Medium, fontSize = 15.sp)
Text(
// Says who learns what. The bridge operator sees a new client; the
// contact learns you use this bridge. Neither is nothing.
"They get the line and decide for themselves. Whoever runs the bridge will " +
"see one more person connecting, and your contact will know you use it.",
fontSize = 12.sp,
color = Palette.SecondaryText,
)
for (buddy in buddies) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = Space.xs),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
buddy.displayName.ifBlank { buddy.handle },
fontSize = 14.sp,
modifier = Modifier.weight(1f),
)
KitSecondaryButton(
text = if (sharedWith.contains(buddy.userId)) "Sent" else "Send",
onClick = {
model?.shareBridge(buddy.userId)
sharedWith = sharedWith + buddy.userId
},
// ⚠️ Not disabled after sending. An operator rotates a
// bridge and the same person needs the new line; a button
// that never re-arms would make that impossible from here.
tag = "obfs4-share-${buddy.userId}",
)
}
}
}
Spacer(Modifier.height(Space.sm))
Text(