diff --git a/BountyBoard-debug.apk b/BountyBoard-debug.apk index d160305..491bc67 100644 Binary files a/BountyBoard-debug.apk and b/BountyBoard-debug.apk differ diff --git a/app/src/main/java/com/anypreta/bountyboard/MainActivity.kt b/app/src/main/java/com/anypreta/bountyboard/MainActivity.kt index be347b4..b63ee01 100644 --- a/app/src/main/java/com/anypreta/bountyboard/MainActivity.kt +++ b/app/src/main/java/com/anypreta/bountyboard/MainActivity.kt @@ -57,4 +57,14 @@ class MainActivity : ComponentActivity() { val container = (application as BountyBoardApp).container intent.getStringExtra(PushNotifier.EXTRA_ROUTE)?.let { container.setDeepLink(it) } } + + override fun onStart() { + super.onStart() + (application as BountyBoardApp).container.appForeground = true + } + + override fun onStop() { + super.onStop() + (application as BountyBoardApp).container.appForeground = false + } } diff --git a/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt b/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt index 3a635c0..88346e0 100644 --- a/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt +++ b/app/src/main/java/com/anypreta/bountyboard/data/AppContainer.kt @@ -63,6 +63,13 @@ class AppContainer( .crossfade(true) .build() + /** The live-update WebSocket (chat + notifications + board). */ + val live = com.anypreta.bountyboard.data.ws.LiveSocket(okHttp, gson) { _baseUrl.value } + + /** Whether an Activity is currently in the foreground (drives background notifications). */ + @Volatile + var appForeground: Boolean = false + /** A pending in-app navigation target (set when the user taps a system notification). */ val pendingDeepLink = MutableStateFlow(null) diff --git a/app/src/main/java/com/anypreta/bountyboard/data/ws/LiveSocket.kt b/app/src/main/java/com/anypreta/bountyboard/data/ws/LiveSocket.kt new file mode 100644 index 0000000..50a5c68 --- /dev/null +++ b/app/src/main/java/com/anypreta/bountyboard/data/ws/LiveSocket.kt @@ -0,0 +1,136 @@ +package com.anypreta.bountyboard.data.ws + +import com.anypreta.bountyboard.data.model.Message +import com.anypreta.bountyboard.data.model.Notification +import com.google.gson.Gson +import com.google.gson.JsonObject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * The single multiplexed live-update WebSocket (server ยง2.2): one connection per + * session carrying `chat`, `board` and `notifications` channels. Auth rides on the + * shared cookie jar; the server allows clients that send no Origin header (we don't), + * so no server changes are required. Auto-reconnects with backoff while [connect]ed. + */ +class LiveSocket( + baseClient: OkHttpClient, + private val gson: Gson, + private val baseUrlProvider: () -> String, +) { + private val client = baseClient.newBuilder() + .pingInterval(20, TimeUnit.SECONDS) // keep the socket alive through proxies + .readTimeout(0, TimeUnit.SECONDS) + .build() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val running = AtomicBoolean(false) + @Volatile private var socket: WebSocket? = null + @Volatile private var reconnectJob: kotlinx.coroutines.Job? = null + + private val _chat = MutableSharedFlow(extraBufferCapacity = 64) + val chatMessages: SharedFlow = _chat.asSharedFlow() + + private val _notifications = MutableSharedFlow(extraBufferCapacity = 64) + val notifications: SharedFlow = _notifications.asSharedFlow() + + private val _board = MutableSharedFlow(extraBufferCapacity = 64) + val boardEvents: SharedFlow = _board.asSharedFlow() + + private val _connected = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) + val connected: SharedFlow = _connected.asSharedFlow() + + fun connect() { + if (!running.compareAndSet(false, true)) return + open() + } + + fun disconnect() { + running.set(false) + reconnectJob?.cancel() + socket?.close(1000, "bye") + socket = null + } + + private fun open() { + if (!running.get()) return + val url = baseUrlProvider().trimEnd('/') + "/ws" + val request = Request.Builder().url(url).build() + socket = client.newWebSocket(request, listener) + } + + private fun scheduleReconnect() { + if (!running.get()) return + if (reconnectJob?.isActive == true) return + reconnectJob = scope.launch { + var attempt = 0 + while (isActive && running.get() && socket == null) { + val backoff = minOf(30_000L, 1_000L * (1L shl minOf(attempt, 4))) + delay(backoff) + attempt++ + open() + // give the open a moment; if it failed, onFailure nulls socket again + delay(2_000) + } + } + } + + private val listener = object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + _connected.tryEmit(true) + } + + override fun onMessage(webSocket: WebSocket, text: String) { + try { + val env = gson.fromJson(text, JsonObject::class.java) ?: return + val channel = env.get("channel")?.asString ?: return + val event = env.get("event")?.asString ?: return + val data = env.get("data") + when (channel) { + "chat" -> if (event == "message" && data != null) { + runCatching { gson.fromJson(data, Message::class.java) } + .getOrNull()?.let { _chat.tryEmit(it) } + } + "notifications" -> if (event == "notification" && data != null) { + runCatching { gson.fromJson(data, Notification::class.java) } + .getOrNull()?.let { _notifications.tryEmit(it) } + } + "board" -> { + val taskId = data?.asJsonObject?.get("taskId")?.asString + _board.tryEmit(BoardEvent(event, taskId)) + } + } + } catch (_: Exception) { + // ignore malformed frames + } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + socket = null + _connected.tryEmit(false) + scheduleReconnect() + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + socket = null + _connected.tryEmit(false) + scheduleReconnect() + } + } +} + +data class BoardEvent(val event: String, val taskId: String?) diff --git a/app/src/main/java/com/anypreta/bountyboard/ui/components/RichText.kt b/app/src/main/java/com/anypreta/bountyboard/ui/components/RichText.kt new file mode 100644 index 0000000..86c1007 --- /dev/null +++ b/app/src/main/java/com/anypreta/bountyboard/ui/components/RichText.kt @@ -0,0 +1,105 @@ +package com.anypreta.bountyboard.ui.components + +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import com.anypreta.bountyboard.ui.theme.BB + +/** + * Renders server-sanitized rich text (or plain text) with **clickable links** โ€” + * both `` anchors and bare http(s)/www URLs become tappable, opening in the + * browser via the platform UriHandler. Used for chat messages, comments, and + * task descriptions. + */ +@Composable +fun RichText( + text: String, + modifier: Modifier = Modifier, + color: Color = BB.colors.text, + linkColor: Color = BB.colors.accent, + style: TextStyle = LocalTextStyle.current, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, +) { + val tokens = remember(text) { tokenize(text) } + val annotated = buildAnnotatedString { + val linkStyles = TextLinkStyles( + style = SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline), + ) + tokens.forEach { t -> + when (t) { + is Token.Plain -> append(t.text) + is Token.Link -> withLink(LinkAnnotation.Url(t.url, linkStyles)) { append(t.label) } + } + } + } + Text( + annotated, + modifier = modifier, + color = color, + style = style, + maxLines = maxLines, + overflow = overflow, + ) +} + +private sealed interface Token { + data class Plain(val text: String) : Token + data class Link(val label: String, val url: String) : Token +} + +private val anchorRegex = + Regex("""]*?href\s*=\s*["']([^"']+)["'][^>]*>(.*?)""", RegexOption.DOT_MATCHES_ALL) +private val urlRegex = Regex("""(https?://[^\s<]+)|(www\.[^\s<]+)""") + +private fun tokenize(html: String): List { + val out = mutableListOf() + var last = 0 + for (m in anchorRegex.findAll(html)) { + if (m.range.first > last) { + addPlainWithUrls(out, stripTags(html.substring(last, m.range.first))) + } + val url = normalizeUrl(m.groupValues[1]) + val label = stripTags(m.groupValues[2]).ifBlank { m.groupValues[1] } + out += Token.Link(label, url) + last = m.range.last + 1 + } + if (last < html.length) { + addPlainWithUrls(out, stripTags(html.substring(last))) + } + return out.ifEmpty { listOf(Token.Plain(stripTags(html))) } +} + +/** Split a plain string into text + auto-detected bare-URL link tokens. */ +private fun addPlainWithUrls(out: MutableList, text: String) { + if (text.isEmpty()) return + var last = 0 + for (m in urlRegex.findAll(text)) { + if (m.range.first > last) out += Token.Plain(text.substring(last, m.range.first)) + val raw = m.value.trimEnd('.', ',', ')', ']', '!', '?', ';', ':') + out += Token.Link(raw, normalizeUrl(raw)) + last = m.range.first + raw.length + } + if (last < text.length) out += Token.Plain(text.substring(last)) +} + +private fun normalizeUrl(u: String): String = + if (u.startsWith("http://") || u.startsWith("https://")) u else "https://$u" + +private fun stripTags(s: String): String = + s.replace(Regex(""), "\n") + .replace(Regex("

||"), "\n") + .replace(Regex("<[^>]+>"), "") + .replace("&", "&").replace("<", "<").replace(">", ">") + .replace(""", "\"").replace("'", "'").replace(" ", " ") diff --git a/app/src/main/java/com/anypreta/bountyboard/ui/messaging/ConversationScreen.kt b/app/src/main/java/com/anypreta/bountyboard/ui/messaging/ConversationScreen.kt index 3975131..e155938 100644 --- a/app/src/main/java/com/anypreta/bountyboard/ui/messaging/ConversationScreen.kt +++ b/app/src/main/java/com/anypreta/bountyboard/ui/messaging/ConversationScreen.kt @@ -89,6 +89,21 @@ class ConversationViewModel( init { load() + // Live: merge messages pushed over the WebSocket for this conversation. + viewModelScope.launch { + container.live.chatMessages.collect { m -> + if (m.conversationId == conversationId) { + mergeMessage(m) + markRead() + } + } + } + } + + private fun mergeMessage(m: Message) { + if (messages.any { it.id == m.id }) return + messages = (messages + m).sortedBy { it.id } + resolveNames(setOf(m.senderId)) } fun load() { @@ -327,10 +342,11 @@ private fun MessageBubble( } } if (message.body.isNotBlank()) { - Text( - htmlToPlain(message.body), + com.anypreta.bountyboard.ui.components.RichText( + message.body, style = MaterialTheme.typography.bodyMedium, color = if (mine) p.onAccent else p.text, + linkColor = if (mine) p.onAccent else p.accent, ) } } diff --git a/app/src/main/java/com/anypreta/bountyboard/ui/nav/MainShell.kt b/app/src/main/java/com/anypreta/bountyboard/ui/nav/MainShell.kt index 2044249..3707d98 100644 --- a/app/src/main/java/com/anypreta/bountyboard/ui/nav/MainShell.kt +++ b/app/src/main/java/com/anypreta/bountyboard/ui/nav/MainShell.kt @@ -39,9 +39,11 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.DrawerValue import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -57,6 +59,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.anypreta.bountyboard.data.push.PushNotifier import com.anypreta.bountyboard.di.LocalAppContainer import com.anypreta.bountyboard.di.containerVMFactory import com.anypreta.bountyboard.ui.admin.AdminAuditScreen @@ -118,6 +121,45 @@ fun MainShell() { if (pushEnabled) container.schedulePush() else container.cancelPush() } + // Live updates: keep the WebSocket connected for the whole authenticated session. + val context = LocalContext.current + DisposableEffect(Unit) { + container.live.connect() + onDispose { /* kept alive across navigation; closed on logout */ } + } + LaunchedEffect(Unit) { + // Live notifications โ†’ bump the bell, and (when backgrounded) raise a system alert. + launch { + container.live.notifications.collect { n -> + shellVm.refreshUnread() + if (!container.appForeground) { + PushNotifier.notifyAlert( + context, + id = ("a" + n.id).hashCode(), + title = n.title.ifBlank { "New alert" }, + body = com.anypreta.bountyboard.ui.util.htmlToPlain(n.body).ifBlank { "You have a new alert." }, + route = PushNotifier.routeForServerLink(n.link), + ) + } + } + } + // Live chat โ†’ when backgrounded and it isn't our own message, raise a system alert. + launch { + container.live.chatMessages.collect { m -> + val mine = m.senderId == container.session.user.value?.id + if (!container.appForeground && !mine) { + PushNotifier.notifyMessage( + context, + id = ("m" + m.conversationId).hashCode(), + title = "New message", + body = com.anypreta.bountyboard.ui.util.htmlToPlain(m.body).ifBlank { "New message" }, + route = "conversation/${m.conversationId}", + ) + } + } + } + } + // Deep link from a tapped system notification. val deepLink by container.pendingDeepLink.collectAsStateWithLifecycle() LaunchedEffect(deepLink) { diff --git a/app/src/main/java/com/anypreta/bountyboard/ui/nav/ShellViewModel.kt b/app/src/main/java/com/anypreta/bountyboard/ui/nav/ShellViewModel.kt index 709b63f..b2ce695 100644 --- a/app/src/main/java/com/anypreta/bountyboard/ui/nav/ShellViewModel.kt +++ b/app/src/main/java/com/anypreta/bountyboard/ui/nav/ShellViewModel.kt @@ -48,6 +48,7 @@ class ShellViewModel(private val container: AppContainer) : ViewModel() { container.repo.action { logout() } } catch (_: Exception) { } + container.live.disconnect() container.cancelPush() container.clearPushState() container.cookieJar.clear() diff --git a/app/src/main/java/com/anypreta/bountyboard/ui/task/TaskDetailScreen.kt b/app/src/main/java/com/anypreta/bountyboard/ui/task/TaskDetailScreen.kt index d991999..200fec8 100644 --- a/app/src/main/java/com/anypreta/bountyboard/ui/task/TaskDetailScreen.kt +++ b/app/src/main/java/com/anypreta/bountyboard/ui/task/TaskDetailScreen.kt @@ -46,6 +46,7 @@ import com.anypreta.bountyboard.ui.components.Hairline import com.anypreta.bountyboard.ui.components.MessageBox import com.anypreta.bountyboard.ui.components.MessageKind import com.anypreta.bountyboard.ui.components.NoteDialog +import com.anypreta.bountyboard.ui.components.RichText import com.anypreta.bountyboard.ui.components.ScreenState import com.anypreta.bountyboard.ui.components.SectionLabel import com.anypreta.bountyboard.ui.components.StatusBadge @@ -177,7 +178,7 @@ fun TaskDetailScreen(taskId: String, onBack: () -> Unit, onOpenProfile: (String) // Description if (task.description.isNotBlank()) { SectionCard("Description") { - Text(htmlToPlain(task.description), style = MaterialTheme.typography.bodyLarge, color = p.text) + RichText(task.description, style = MaterialTheme.typography.bodyLarge, color = p.text) } } @@ -260,7 +261,7 @@ fun TaskDetailScreen(taskId: String, onBack: () -> Unit, onOpenProfile: (String) Text(vm.nameFor(c.authorId), style = MaterialTheme.typography.titleSmall, color = p.text) Text(relativeTime(c.at), style = MaterialTheme.typography.labelSmall, color = p.muted) } - Text(htmlToPlain(c.body), style = MaterialTheme.typography.bodyMedium, color = p.text) + RichText(c.body, style = MaterialTheme.typography.bodyMedium, color = p.text) } } }