From 1cb924ec4958d20f89978c866cf5a758da14859e Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 18 Aug 2026 14:19:33 +0200 Subject: [PATCH 1/2] open URLs on host machine --- .../api/sentry-android-buddy.api | 39 ++++++-- .../android/buddy/BuddySentryUiLinks.kt | 2 +- .../io/sentry/android/buddy/SentryBuddy.kt | 5 +- .../buddy/SentryBuddyHttpOpenUrlApi.kt | 93 +++++++++++++++++++ .../android/buddy/SentryBuddyOpenUrlApi.kt | 23 +++++ .../android/buddy/SentryBuddyOptions.kt | 1 + .../android/buddy/SentryBuddySession.kt | 10 ++ .../io/sentry/android/buddy/SentryBuddyUi.kt | 37 ++++---- .../java/io/sentry/android/buddy/internal.kt | 7 +- .../buddy/SentryBuddyHttpOpenUrlApiTest.kt | 53 +++++++++++ .../android/SentryBuddySampleIntegration.kt | 2 + 11 files changed, 243 insertions(+), 29 deletions(-) create mode 100644 sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyHttpOpenUrlApi.kt create mode 100644 sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyOpenUrlApi.kt create mode 100644 sentry-android-buddy/src/test/java/io/sentry/android/buddy/SentryBuddyHttpOpenUrlApiTest.kt diff --git a/sentry-android-buddy/api/sentry-android-buddy.api b/sentry-android-buddy/api/sentry-android-buddy.api index 4c197987ab..711df6cf81 100644 --- a/sentry-android-buddy/api/sentry-android-buddy.api +++ b/sentry-android-buddy/api/sentry-android-buddy.api @@ -307,6 +307,7 @@ public final class io/sentry/android/buddy/ComposableSingletons$SentryBuddyUiKt public static final field INSTANCE Lio/sentry/android/buddy/ComposableSingletons$SentryBuddyUiKt; public fun ()V public final fun getLambda$-112358923$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1179039684$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$-1194038066$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$-1377717530$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$-1422433876$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function2; @@ -314,14 +315,13 @@ public final class io/sentry/android/buddy/ComposableSingletons$SentryBuddyUiKt public final fun getLambda$-1944467248$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function2; public final fun getLambda$-218216137$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$-682238859$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-90711693$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$1040476556$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$1215034671$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1352873802$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$1525882558$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$1644532347$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$1884831281$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$1892283088$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$342625061$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$356797945$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function3; public final fun getLambda$567083575$sentry_android_buddy_release ()Lkotlin/jvm/functions/Function2; } @@ -334,6 +334,12 @@ public final class io/sentry/android/buddy/DummySentryBuddyFlowAnalysesApi : io/ public fun submit (Lio/sentry/android/buddy/FlowAnalysisRequest;)Lio/sentry/android/buddy/FlowAnalysisResponse; } +public final class io/sentry/android/buddy/DummySentryBuddyOpenUrlApi : io/sentry/android/buddy/SentryBuddyOpenUrlApi { + public static final field $stable I + public static final field INSTANCE Lio/sentry/android/buddy/DummySentryBuddyOpenUrlApi; + public fun open (Landroid/content/Context;Ljava/lang/String;)V +} + public final class io/sentry/android/buddy/FlowAnalysisEvent : io/sentry/JsonSerializable { public static final field $stable I public fun (Ljava/lang/String;JLjava/util/Map;)V @@ -483,24 +489,39 @@ public final class io/sentry/android/buddy/SentryBuddyHttpFlowAnalysesApi : io/s public fun submit (Lio/sentry/android/buddy/FlowAnalysisRequest;)Lio/sentry/android/buddy/FlowAnalysisResponse; } +public final class io/sentry/android/buddy/SentryBuddyHttpOpenUrlApi : io/sentry/android/buddy/SentryBuddyOpenUrlApi { + public static final field $stable I + public fun (Ljava/lang/String;)V + public fun (Ljava/lang/String;Lokhttp3/OkHttpClient;)V + public synthetic fun (Ljava/lang/String;Lokhttp3/OkHttpClient;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun open (Landroid/content/Context;Ljava/lang/String;)V +} + +public abstract interface class io/sentry/android/buddy/SentryBuddyOpenUrlApi { + public abstract fun open (Landroid/content/Context;Ljava/lang/String;)V +} + public final class io/sentry/android/buddy/SentryBuddyOptions { public static final field $stable I public fun ()V public fun (Z)V public fun (ZZ)V public fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;)V - public fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Ljava/lang/String;)V - public fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Ljava/lang/String;Ljava/lang/String;)V - public fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V - public synthetic fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;)V + public fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;Ljava/lang/String;)V + public fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;Ljava/lang/String;Ljava/lang/String;)V + public fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V + public synthetic fun (ZZLio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getEnabled ()Z public final fun getFlowAnalysesApi ()Lio/sentry/android/buddy/SentryBuddyFlowAnalysesApi; + public final fun getOpenUrlApi ()Lio/sentry/android/buddy/SentryBuddyOpenUrlApi; public final fun getSentryUiBaseUrl ()Ljava/lang/String; public final fun getSentryUiOrganizationSlug ()Ljava/lang/String; public final fun getSentryUiProjectId ()Ljava/lang/String; public final fun getShowOverlay ()Z public final fun setEnabled (Z)V public final fun setFlowAnalysesApi (Lio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;)V + public final fun setOpenUrlApi (Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;)V public final fun setSentryUiBaseUrl (Ljava/lang/String;)V public final fun setSentryUiOrganizationSlug (Ljava/lang/String;)V public final fun setSentryUiProjectId (Ljava/lang/String;)V @@ -518,13 +539,15 @@ public final class io/sentry/android/buddy/SentryBuddySessionController { public fun ()V public fun (Lio/sentry/android/buddy/SentryBuddyRecorderFacade;)V public fun (Lio/sentry/android/buddy/SentryBuddyRecorderFacade;Lio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;)V - public fun (Lio/sentry/android/buddy/SentryBuddyRecorderFacade;Lio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lkotlin/jvm/functions/Function0;)V - public synthetic fun (Lio/sentry/android/buddy/SentryBuddyRecorderFacade;Lio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lio/sentry/android/buddy/SentryBuddyRecorderFacade;Lio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;)V + public fun (Lio/sentry/android/buddy/SentryBuddyRecorderFacade;Lio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;Lkotlin/jvm/functions/Function0;)V + public synthetic fun (Lio/sentry/android/buddy/SentryBuddyRecorderFacade;Lio/sentry/android/buddy/SentryBuddyFlowAnalysesApi;Lio/sentry/android/buddy/SentryBuddyOpenUrlApi;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun analyze ()V public final fun briefRecording ()V public final fun close ()V public final fun getState ()Lio/sentry/android/buddy/SentryBuddySessionState; public final fun open ()V + public final fun openUrl (Landroid/content/Context;Ljava/lang/String;)V public final fun pollFlowAnalysis ()V public final fun recordAgain ()V public final fun startRecording ()V diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/BuddySentryUiLinks.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/BuddySentryUiLinks.kt index cc4b9bccd1..bf4ec99ba7 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/BuddySentryUiLinks.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/BuddySentryUiLinks.kt @@ -13,7 +13,7 @@ internal data class BuddySentryUiLinks( return when (item.category) { BuddyLiveFeedItem.Category.ERROR -> item.timelineItem.data.stringValue(DATA_EVENT_ID)?.let { eventId -> - "$baseUrl/issues/?project=${projectId.urlEncode()}&query=${"event.id:$eventId".urlEncode()}" + "$baseUrl/issues/?project=${projectId.urlEncode()}&query=${"id:$eventId".urlEncode()}" } BuddyLiveFeedItem.Category.SLOW_SPAN, BuddyLiveFeedItem.Category.FAILED_SPAN -> traceLink(baseUrl, projectId, item) diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddy.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddy.kt index a5a5e885a5..711adaf647 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddy.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddy.kt @@ -194,7 +194,10 @@ public object SentryBuddy { return null } return BuddyOverlayManager( - SentryBuddySessionController(flowAnalysesApi = options.flowAnalysesApi) + SentryBuddySessionController( + flowAnalysesApi = options.flowAnalysesApi, + openUrlApi = options.openUrlApi, + ) ) .also { it.updateOptions(options) } } diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyHttpOpenUrlApi.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyHttpOpenUrlApi.kt new file mode 100644 index 0000000000..59ba03c187 --- /dev/null +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyHttpOpenUrlApi.kt @@ -0,0 +1,93 @@ +package io.sentry.android.buddy + +import android.content.Context +import io.sentry.ILogger +import io.sentry.JsonObjectReader +import io.sentry.JsonSerializable +import io.sentry.JsonSerializer +import io.sentry.ObjectWriter +import io.sentry.SentryOptions +import java.io.IOException +import java.io.StringReader +import java.io.StringWriter +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +public class SentryBuddyHttpOpenUrlApi +@JvmOverloads +public constructor( + private val baseUrl: String, + private val client: OkHttpClient = OkHttpClient(), +) : SentryBuddyOpenUrlApi { + private val json = JsonSerializer(SentryOptions()) + + override fun open(context: Context, url: String) { + val httpRequest = + Request.Builder() + .url(baseUrl.toHttpUrl().newBuilder().addPathSegments("v1/open-url").build()) + .post(serialize(OpenUrlRequest(url)).toRequestBody(JSON_MEDIA_TYPE)) + .build() + execute(httpRequest) + } + + private fun serialize(request: OpenUrlRequest): String { + val writer = StringWriter() + json.serialize(request, writer) + return writer.toString() + } + + private fun execute(request: Request) { + try { + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException(response.errorMessage()) + } + } + } catch (exception: IOException) { + throw IllegalStateException( + "Failed to call open URL bridge: ${exception.message}", + exception, + ) + } + } + + private fun Response.errorMessage(): String { + val error = extractError(body?.string().orEmpty()) + return buildString { + append("Open URL bridge request failed with HTTP ").append(code) + error?.let { append(": ").append(it) } + } + } + + private fun extractError(body: String): String? { + if (body.isBlank()) { + return null + } + return try { + (JsonObjectReader(StringReader(body)).use { it.nextObjectOrNull() } as? Map<*, *>) + ?.get("error") + ?.toString() + } catch (_: Exception) { + body.take(200) + } + } + + private companion object { + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + } +} + +private data class OpenUrlRequest(val url: String) : JsonSerializable { + @Throws(IOException::class) + override fun serialize(writer: ObjectWriter, logger: ILogger) { + writer.beginObject() + writer.name("url").value(url) + writer.endObject() + } +} diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyOpenUrlApi.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyOpenUrlApi.kt new file mode 100644 index 0000000000..f06b1e5207 --- /dev/null +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyOpenUrlApi.kt @@ -0,0 +1,23 @@ +package io.sentry.android.buddy + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import org.jetbrains.annotations.ApiStatus + +@ApiStatus.Experimental +public interface SentryBuddyOpenUrlApi { + public fun open(context: Context, url: String) +} + +@ApiStatus.Experimental +public object DummySentryBuddyOpenUrlApi : SentryBuddyOpenUrlApi { + override fun open(context: Context, url: String) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) + } catch (_: ActivityNotFoundException) { + // A debug overlay should not crash the app when no browser can handle the link. + } + } +} diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyOptions.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyOptions.kt index 5e9bbf2eea..77a5dcba13 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyOptions.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyOptions.kt @@ -9,6 +9,7 @@ public constructor( public var enabled: Boolean = true, public var showOverlay: Boolean = true, public var flowAnalysesApi: SentryBuddyFlowAnalysesApi = DummySentryBuddyFlowAnalysesApi, + public var openUrlApi: SentryBuddyOpenUrlApi = DummySentryBuddyOpenUrlApi, public var sentryUiBaseUrl: String? = null, public var sentryUiOrganizationSlug: String? = null, public var sentryUiProjectId: String? = null, diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt index d5cecb85f5..b371362a73 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt @@ -1,5 +1,6 @@ package io.sentry.android.buddy +import android.content.Context import java.util.Locale import org.jetbrains.annotations.ApiStatus @@ -189,6 +190,7 @@ public class SentryBuddySessionController public constructor( private val recorderFacade: SentryBuddyRecorderFacade = RealSentryBuddyRecorderFacade, private val flowAnalysesApi: SentryBuddyFlowAnalysesApi = DummySentryBuddyFlowAnalysesApi, + private val openUrlApi: SentryBuddyOpenUrlApi = DummySentryBuddyOpenUrlApi, private val clock: () -> Long = { System.currentTimeMillis() }, ) { public var state: SentryBuddySessionState = SentryBuddySessionState.Closed @@ -343,6 +345,14 @@ public constructor( state = SentryBuddySessionState.Intro } + public fun openUrl(context: Context, url: String) { + try { + openUrlApi.open(context, url) + } catch (_: IllegalStateException) { + // A debug overlay should not disrupt the current session when the bridge is unreachable. + } + } + internal fun recordTransientEvent(text: String) { val event: TransientRecordingEvent val listeners: List<(TransientRecordingEvent) -> Unit> diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt index 442ecf1222..b6068a2b22 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt @@ -1,10 +1,7 @@ package io.sentry.android.buddy -import android.content.ActivityNotFoundException import android.content.Context -import android.content.Intent import android.graphics.Rect -import android.net.Uri import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween @@ -162,6 +159,10 @@ private fun SentryBuddyOverlayContent( } } + fun openUrl(context: Context, url: String) { + analysisScope.launch { withContext(Dispatchers.IO) { controller.openUrl(context, url) } } + } + LaunchedEffect(state) { if (state !is SentryBuddySessionState.Closed) { while (true) { @@ -254,6 +255,7 @@ private fun SentryBuddyOverlayContent( nowMs = nowMs, onDispatch = { dispatch(it) }, onAnalyze = { dispatchAnalysis { analyze() } }, + onOpenUrl = { context, url -> openUrl(context, url) }, ) } } @@ -487,6 +489,7 @@ private fun BuddySheet( nowMs: Long, onDispatch: (SentryBuddySessionController.() -> Unit) -> Unit, onAnalyze: () -> Unit, + onOpenUrl: (Context, String) -> Unit, ) { if (state is SentryBuddySessionState.Closed || state is SentryBuddySessionState.Recording) { return @@ -510,7 +513,7 @@ private fun BuddySheet( ) { when (state) { SentryBuddySessionState.LiveFeed -> - LiveFeedSheet(liveFeed, sentryUiLinks, nowMs, onDispatch) + LiveFeedSheet(liveFeed, sentryUiLinks, nowMs, onDispatch, onOpenUrl) SentryBuddySessionState.Intro -> IntroSheet(onDispatch) is SentryBuddySessionState.StoppedSummary -> StoppedSummarySheet(state, onDispatch) is SentryBuddySessionState.Briefing -> BriefingSheet(state, onDispatch, onAnalyze) @@ -578,9 +581,10 @@ private fun LiveFeedSheet( sentryUiLinks: BuddySentryUiLinks, nowMs: Long, onDispatch: (SentryBuddySessionController.() -> Unit) -> Unit, + onOpenUrl: (Context, String) -> Unit, ) { SheetTitle("Sentry Buddy", "Live Feed") - AttentionCard(liveFeed, sentryUiLinks, nowMs) + AttentionCard(liveFeed, sentryUiLinks, nowMs, onOpenUrl) Button( modifier = Modifier.fillMaxWidth().height(56.dp), colors = ButtonDefaults.buttonColors(containerColor = BuddyPurple), @@ -597,12 +601,17 @@ private fun LiveFeedSheet( if (liveFeed.items.isEmpty()) { EmptyLiveFeedCard() } else { - LiveFeedRows(liveFeed.items, sentryUiLinks, nowMs) + LiveFeedRows(liveFeed.items, sentryUiLinks, nowMs, onOpenUrl) } } @Composable -private fun AttentionCard(liveFeed: BuddyLiveFeed, sentryUiLinks: BuddySentryUiLinks, nowMs: Long) { +private fun AttentionCard( + liveFeed: BuddyLiveFeed, + sentryUiLinks: BuddySentryUiLinks, + nowMs: Long, + onOpenUrl: (Context, String) -> Unit, +) { val item = liveFeed.latestAdverseItem if (item == null) { Card(border = CardDefaults.outlinedCardBorder()) { @@ -625,8 +634,7 @@ private fun AttentionCard(liveFeed: BuddyLiveFeed, sentryUiLinks: BuddySentryUiL val context = LocalContext.current val link = sentryUiLinks.linkFor(item) Card( - modifier = - Modifier.clickable(enabled = link != null) { link?.let { openSentryLink(context, it) } }, + modifier = Modifier.clickable(enabled = link != null) { link?.let { onOpenUrl(context, it) } }, colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.10f)), border = CardDefaults.outlinedCardBorder(), ) { @@ -694,6 +702,7 @@ private fun LiveFeedRows( items: List, sentryUiLinks: BuddySentryUiLinks, nowMs: Long, + onOpenUrl: (Context, String) -> Unit, ) { val context = LocalContext.current Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { @@ -703,7 +712,7 @@ private fun LiveFeedRows( val link = sentryUiLinks.linkFor(item) Card( modifier = - Modifier.clickable(enabled = link != null) { link?.let { openSentryLink(context, it) } }, + Modifier.clickable(enabled = link != null) { link?.let { onOpenUrl(context, it) } }, border = CardDefaults.outlinedCardBorder(), ) { Row( @@ -1060,14 +1069,6 @@ private fun relativeTime(timestampMs: Long, nowMs: Long): String { return "${ageSeconds / 60}m ago" } -private fun openSentryLink(context: Context, link: String) { - try { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(link))) - } catch (_: ActivityNotFoundException) { - // A debug overlay should not crash the app when no browser can handle the link. - } -} - private fun Map.mapValue(key: String): Map<*, *> = this[key] as? Map<*, *> ?: emptyMap() diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/internal.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/internal.kt index 974d986f58..c3e2602ac3 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/internal.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/internal.kt @@ -567,7 +567,12 @@ internal class BuddyActivityLifecycleCallbacks( } if (overlayManager == null) { overlayManager = - BuddyOverlayManager(SentryBuddySessionController(flowAnalysesApi = options.flowAnalysesApi)) + BuddyOverlayManager( + SentryBuddySessionController( + flowAnalysesApi = options.flowAnalysesApi, + openUrlApi = options.openUrlApi, + ) + ) } overlayManager?.updateOptions(options) } diff --git a/sentry-android-buddy/src/test/java/io/sentry/android/buddy/SentryBuddyHttpOpenUrlApiTest.kt b/sentry-android-buddy/src/test/java/io/sentry/android/buddy/SentryBuddyHttpOpenUrlApiTest.kt new file mode 100644 index 0000000000..7104f6e746 --- /dev/null +++ b/sentry-android-buddy/src/test/java/io/sentry/android/buddy/SentryBuddyHttpOpenUrlApiTest.kt @@ -0,0 +1,53 @@ +package io.sentry.android.buddy + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertFailsWith +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.runner.RunWith +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [31]) +class SentryBuddyHttpOpenUrlApiTest { + private val server = MockWebServer() + + @AfterTest + fun tearDown() { + server.shutdown() + } + + @Test + fun `open posts url to bridge`() { + server.enqueue(MockResponse().setResponseCode(200)) + val api = SentryBuddyHttpOpenUrlApi(server.url("/").toString()) + + api.open(RuntimeEnvironment.getApplication(), "https://sentry.io/issues/1") + + val recordedRequest = server.takeRequest() + assertThat(recordedRequest.method).isEqualTo("POST") + assertThat(recordedRequest.path).isEqualTo("/v1/open-url") + assertThat(recordedRequest.body.readUtf8()) + .isEqualTo("""{"url":"https://sentry.io/issues/1"}""") + } + + @Test + fun `http errors include bridge error message`() { + server.enqueue( + MockResponse().setResponseCode(400).setBody("""{"error":"url must use https"}""") + ) + val api = SentryBuddyHttpOpenUrlApi(server.url("/").toString()) + + val error = + assertFailsWith { + api.open(RuntimeEnvironment.getApplication(), "http://sentry.io") + } + + assertThat(error).hasMessageThat().contains("HTTP 400") + assertThat(error).hasMessageThat().contains("url must use https") + } +} diff --git a/sentry-samples/sentry-samples-android/src/debug/java/io/sentry/samples/android/SentryBuddySampleIntegration.kt b/sentry-samples/sentry-samples-android/src/debug/java/io/sentry/samples/android/SentryBuddySampleIntegration.kt index d19adcfdc6..912fbdbdd1 100644 --- a/sentry-samples/sentry-samples-android/src/debug/java/io/sentry/samples/android/SentryBuddySampleIntegration.kt +++ b/sentry-samples/sentry-samples-android/src/debug/java/io/sentry/samples/android/SentryBuddySampleIntegration.kt @@ -3,12 +3,14 @@ package io.sentry.samples.android import android.app.Application import io.sentry.android.buddy.SentryBuddy import io.sentry.android.buddy.SentryBuddyHttpFlowAnalysesApi +import io.sentry.android.buddy.SentryBuddyHttpOpenUrlApi object SentryBuddySampleIntegration { @JvmStatic fun install(application: Application) { SentryBuddy.install(application) { flowAnalysesApi = SentryBuddyHttpFlowAnalysesApi("http://10.0.2.2:8080") + openUrlApi = SentryBuddyHttpOpenUrlApi("http://10.0.2.2:8080") sentryUiBaseUrl = "https://sentry-sdks.sentry.io" sentryUiOrganizationSlug = "sentry-sdks" sentryUiProjectId = "5428559" From b6f8e14aa77e39c380d1e4642a31f61a3b67e2ee Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 18 Aug 2026 14:30:09 +0200 Subject: [PATCH 2/2] Merge branch 'hackweek/sentry-buddy' into feat/hackweek/sentry-buddy-open-urls-on-host Also fixes an unrelated regression from the previous commit where the Sentry issue search query used "id:" instead of "event.id:". --- .../android/buddy/BuddySentryUiLinks.kt | 2 +- .../android/buddy/SentryBuddySession.kt | 6 +- .../io/sentry/android/buddy/SentryBuddyUi.kt | 435 ++++++++++++++---- 3 files changed, 364 insertions(+), 79 deletions(-) diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/BuddySentryUiLinks.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/BuddySentryUiLinks.kt index bf4ec99ba7..cc4b9bccd1 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/BuddySentryUiLinks.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/BuddySentryUiLinks.kt @@ -13,7 +13,7 @@ internal data class BuddySentryUiLinks( return when (item.category) { BuddyLiveFeedItem.Category.ERROR -> item.timelineItem.data.stringValue(DATA_EVENT_ID)?.let { eventId -> - "$baseUrl/issues/?project=${projectId.urlEncode()}&query=${"id:$eventId".urlEncode()}" + "$baseUrl/issues/?project=${projectId.urlEncode()}&query=${"event.id:$eventId".urlEncode()}" } BuddyLiveFeedItem.Category.SLOW_SPAN, BuddyLiveFeedItem.Category.FAILED_SPAN -> traceLink(baseUrl, projectId, item) diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt index b371362a73..3ef9c7217b 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddySession.kt @@ -210,10 +210,14 @@ public constructor( } internal fun openLiveFeed() { - liveFeed = safeMarkLiveFeedSeen() + liveFeed = safeLiveFeed() state = SentryBuddySessionState.LiveFeed } + internal fun dismissLiveFeedAttention() { + liveFeed = safeMarkLiveFeedSeen() + } + public fun close() { if (state !is SentryBuddySessionState.Recording) { state = SentryBuddySessionState.Closed diff --git a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt index a54523063d..6e31691624 100644 --- a/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt +++ b/sentry-android-buddy/src/main/java/io/sentry/android/buddy/SentryBuddyUi.kt @@ -12,6 +12,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -65,6 +66,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onGloballyPositioned @@ -81,7 +83,10 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import java.util.Locale +import kotlin.math.PI +import kotlin.math.abs import kotlin.math.roundToInt +import kotlin.math.sin import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -273,8 +278,11 @@ private fun BoxScope.BuddyBubble( val bubbleColor = if (isRecording) BuddyRed else BuddyPurple val attentionItem = liveFeed.latestUnviewedAdverseItem val attentionColor = attentionItem?.let { severityColor(it.severity) } + val showAttentionFlames = !isRecording && attentionItem?.severity == Severity.HIGH + val showAttentionSparks = !isRecording && attentionItem?.severity == Severity.MEDIUM val pulseScale = remember { Animatable(1f) } val stopTransition = rememberInfiniteTransition(label = "buddy-floating-stop-button") + val attentionTransition = rememberInfiniteTransition(label = "buddy-attention-ornaments") val stopHaloScale by stopTransition.animateFloat( initialValue = 1.0f, @@ -297,6 +305,17 @@ private fun BoxScope.BuddyBubble( ), label = "buddy-floating-stop-button-halo-alpha", ) + val attentionOrnamentPhase by + attentionTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 950, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "buddy-attention-ornament-phase", + ) var bubbleOffset by remember { mutableStateOf(null) } LaunchedEffect(attentionItem?.id) { @@ -353,6 +372,23 @@ private fun BoxScope.BuddyBubble( modifier = Modifier.size(64.dp), contentAlignment = Alignment.Center, ) { + if (showAttentionFlames) { + AttentionFlames( + phase = attentionOrnamentPhase, + modifier = + Modifier.size(width = 82.dp, height = 34.dp) + .align(Alignment.TopCenter) + .offset(y = (-27).dp), + ) + } else if (showAttentionSparks) { + AttentionSparks( + phase = attentionOrnamentPhase, + modifier = + Modifier.size(width = 86.dp, height = 38.dp) + .align(Alignment.TopCenter) + .offset(y = (-29).dp), + ) + } if (isRecording) { Box( modifier = @@ -429,6 +465,106 @@ private fun BoxScope.BuddyBubble( ) } +@Composable +private fun AttentionFlames(phase: Float, modifier: Modifier = Modifier) { + Canvas(modifier = modifier) { + val baseY = size.height * 0.92f + val centers = listOf(0.28f, 0.50f, 0.72f) + centers.forEachIndexed { index, centerFraction -> + val wave = sin(((phase + index * 0.23f) * 2f * PI).toFloat()) + val centerX = size.width * centerFraction + wave * 2.4f + val height = size.height * (0.56f + index * 0.08f) + wave * 2f + val width = size.width * (0.085f + index * 0.012f) + val tipY = baseY - height + val outerFlame = + Path().apply { + moveTo(centerX, tipY) + cubicTo( + centerX - width * 1.35f, + tipY + height * 0.38f, + centerX - width, + baseY, + centerX, + baseY, + ) + cubicTo( + centerX + width, + baseY, + centerX + width * 1.35f, + tipY + height * 0.38f, + centerX, + tipY, + ) + } + drawPath(outerFlame, BuddyRed.copy(alpha = 0.86f)) + + val innerHeight = height * 0.58f + val innerWidth = width * 0.52f + val innerTipY = baseY - innerHeight + val innerFlame = + Path().apply { + moveTo(centerX, innerTipY) + cubicTo( + centerX - innerWidth, + innerTipY + innerHeight * 0.45f, + centerX - innerWidth * 0.78f, + baseY, + centerX, + baseY, + ) + cubicTo( + centerX + innerWidth * 0.78f, + baseY, + centerX + innerWidth, + innerTipY + innerHeight * 0.45f, + centerX, + innerTipY, + ) + } + drawPath(innerFlame, BuddyGold.copy(alpha = 0.88f)) + } + } +} + +@Composable +private fun AttentionSparks(phase: Float, modifier: Modifier = Modifier) { + Canvas(modifier = modifier) { + val sparks = + listOf( + Spark(0.22f, 0.66f, BuddyGold, 0.0f), + Spark(0.38f, 0.36f, BuddyRed.copy(alpha = 0.75f), 0.35f), + Spark(0.61f, 0.30f, BuddyGold, 0.62f), + Spark(0.78f, 0.64f, BuddyPurple.copy(alpha = 0.70f), 0.20f), + ) + sparks.forEach { spark -> + val wave = sin(((phase + spark.offset) * 2f * PI).toFloat()) + val twinkle = (0.58f + 0.32f * wave).coerceIn(0.35f, 0.95f) + val center = Offset(size.width * spark.x, size.height * spark.y - wave * 2.2f) + val radius = 2.6f + twinkle * 2f + val color = spark.color.copy(alpha = twinkle) + drawLine( + color = color, + start = Offset(center.x - radius, center.y), + end = Offset(center.x + radius, center.y), + strokeWidth = 2.2f, + ) + drawLine( + color = color, + start = Offset(center.x, center.y - radius), + end = Offset(center.x, center.y + radius), + strokeWidth = 2.2f, + ) + drawCircle( + spark.color.copy(alpha = 0.35f * twinkle), + radius = radius * 0.52f, + center = center, + ) + } + } +} + +private data class Spark(val x: Float, val y: Float, val color: Color, val offset: Float) + @Composable private fun BoxScope.TransientRecordingText( event: TransientRecordingEvent?, @@ -601,7 +737,15 @@ private fun LiveFeedSheet( onOpenUrl: (Context, String) -> Unit, ) { SheetTitle("Sentry Buddy", "Live Feed") - AttentionCard(liveFeed, sentryUiLinks, nowMs, onOpenUrl) + val emptyAttentionArtIndex = remember { EmptyAttentionArtIndex.next() } + AttentionCard( + liveFeed = liveFeed, + sentryUiLinks = sentryUiLinks, + nowMs = nowMs, + emptyArtIndex = emptyAttentionArtIndex, + onDismiss = { onDispatch { dismissLiveFeedAttention() } }, + onOpenUrl = onOpenUrl, + ) Button( modifier = Modifier.fillMaxWidth().height(56.dp), colors = ButtonDefaults.buttonColors(containerColor = BuddyPurple), @@ -627,24 +771,36 @@ private fun AttentionCard( liveFeed: BuddyLiveFeed, sentryUiLinks: BuddySentryUiLinks, nowMs: Long, + emptyArtIndex: Int, + onDismiss: () -> Unit, onOpenUrl: (Context, String) -> Unit, ) { + val item = liveFeed.latestUnviewedAdverseItem + val dismissOffset = remember(item?.id) { Animatable(0f) } + val headerAlpha = + if (item == null) { + 0f + } else { + (1f - (-dismissOffset.value / ATTENTION_HEADER_FADE_DISTANCE_PX)).coerceIn(0f, 1f) + } Text( "Needs attention", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, - color = BuddyInk, + color = BuddyInk.copy(alpha = headerAlpha), ) - val item = liveFeed.latestAdverseItem if (item == null) { - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color.Transparent, + shape = RoundedCornerShape(16.dp), + border = CardDefaults.outlinedCardBorder(), ) { - Text("No recent errors or slow spans.", color = BuddyMuted) - Text("OK", color = BuddyPurple, fontWeight = FontWeight.Bold) + EmptyAttentionArt( + index = emptyArtIndex, + modifier = Modifier.fillMaxWidth().height(132.dp).padding(18.dp), + ) } return } @@ -652,61 +808,166 @@ private fun AttentionCard( val color = severityColor(item.severity) val context = LocalContext.current val link = sentryUiLinks.linkFor(item) - Card( - modifier = Modifier.clickable(enabled = link != null) { link?.let { onOpenUrl(context, it) } }, - colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.10f)), + val dismissScope = rememberCoroutineScope() + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color.Transparent, + shape = RoundedCornerShape(16.dp), + border = CardDefaults.outlinedCardBorder(), ) { - Column( - modifier = Modifier.fillMaxWidth().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + BoxWithConstraints( + modifier = + Modifier.fillMaxWidth().heightIn(min = 132.dp).pointerInput(item.id) { + detectDragGestures( + onDragEnd = { + val dismissDistance = size.width.toFloat() + val shouldDismiss = abs(dismissOffset.value) > dismissDistance * 0.35f + dismissScope.launch { + if (shouldDismiss) { + dismissOffset.animateTo(-dismissDistance) + onDismiss() + } else { + dismissOffset.animateTo(0f) + } + } + }, + onDragCancel = { dismissScope.launch { dismissOffset.animateTo(0f) } }, + ) { change, dragAmount -> + change.consume() + val dismissDistance = size.width.toFloat() + val nextOffset = (dismissOffset.value + dragAmount.x).coerceIn(-dismissDistance, 0f) + dismissScope.launch { dismissOffset.snapTo(nextOffset) } + } + } ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, + val widthPx = with(LocalDensity.current) { maxWidth.toPx() } + Box( + modifier = + Modifier.matchParentSize().offset { + IntOffset((dismissOffset.value + widthPx).roundToInt(), 0) + } ) { - LiveFeedCategoryPill(item.category.label, color) - Text( - item.title(), - modifier = Modifier.weight(1f), - color = BuddyInk, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Text( - relativeTime(item.timestamp.time, nowMs), - color = BuddyMuted, - style = MaterialTheme.typography.labelMedium, + EmptyAttentionArt( + index = emptyArtIndex, + modifier = Modifier.fillMaxSize().padding(18.dp), ) } - item.screenContextText()?.let { screenContext -> - Text( - screenContext, - color = BuddyMuted, - style = MaterialTheme.typography.bodySmall, + Box( + modifier = + Modifier.matchParentSize() + .offset { IntOffset(dismissOffset.value.roundToInt(), 0) } + .clickable(enabled = link != null) { link?.let { onOpenUrl(context, it) } } + ) { + AttentionItemContent( + item = item, + liveFeed = liveFeed, + color = color, + nowMs = nowMs, ) } - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - adverseCountChips(liveFeed).forEach { chip -> - Surface( - color = Color.White, - shape = RoundedCornerShape(16.dp), - border = CardDefaults.outlinedCardBorder(), - ) { - Text( - chip, - modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), - color = BuddyInk, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - ) - } + } + } +} + +@Composable +private fun AttentionItemContent( + item: BuddyLiveFeedItem, + liveFeed: BuddyLiveFeed, + color: Color, + nowMs: Long, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LiveFeedCategoryPill(item.category.label, color) + Text( + item.title(), + modifier = Modifier.weight(1f), + color = BuddyInk, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal, + ) + Text( + relativeTime(item.timestamp.time, nowMs), + color = BuddyMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Normal, + ) + } + item.screenContextText()?.let { screenContext -> + Text( + screenContext, + color = BuddyMuted, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Normal, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + adverseCountChips(liveFeed).forEach { chip -> + Surface( + color = Color.White, + shape = RoundedCornerShape(16.dp), + border = CardDefaults.outlinedCardBorder(), + ) { + Text( + chip, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + color = BuddyInk, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Normal, + ) } } } } } +@Composable +private fun EmptyAttentionArt(index: Int, modifier: Modifier = Modifier) { + Canvas(modifier = modifier) { + val variant = index % EMPTY_ATTENTION_ART_VARIANTS + val palette = + when (variant % 5) { + 0 -> listOf(BuddyPurple, BuddyRed, BuddyGold) + 1 -> listOf(BuddyRed, BuddyPurple, BuddyMuted) + 2 -> listOf(BuddyGold, BuddyPurple, BuddyRed) + 3 -> listOf(BuddyPurple, BuddyMuted, BuddyGold) + else -> listOf(BuddyMuted, BuddyRed, BuddyPurple) + } + val w = size.width + val h = size.height + val center = Offset(w * (0.46f + (variant % 3) * 0.04f), h * 0.50f) + drawCircle(palette[0].copy(alpha = 0.12f), radius = h * 0.46f, center = center) + drawCircle( + palette[1].copy(alpha = 0.16f), + radius = h * 0.26f, + center = Offset(w * 0.68f, h * 0.34f), + ) + drawCircle( + palette[2].copy(alpha = 0.18f), + radius = h * 0.18f, + center = Offset(w * 0.28f, h * 0.72f), + ) + val glyph = + Path().apply { + moveTo(w * 0.45f, h * 0.18f) + lineTo(w * 0.27f, h * 0.74f) + lineTo(w * 0.72f, h * 0.74f) + close() + } + drawPath(glyph, palette[0].copy(alpha = 0.20f)) + drawLine(palette[0], Offset(w * 0.38f, h * 0.58f), Offset(w * 0.58f, h * 0.58f), 5f) + drawLine(palette[1], Offset(w * 0.42f, h * 0.46f), Offset(w * 0.62f, h * 0.46f), 4f) + drawLine(palette[2], Offset(w * 0.46f, h * 0.34f), Offset(w * 0.66f, h * 0.34f), 3f) + } +} + @Composable private fun EmptyLiveFeedCard() { Card(border = CardDefaults.outlinedCardBorder()) { @@ -726,31 +987,39 @@ private fun LiveFeedRows( onOpenUrl: (Context, String) -> Unit, ) { val context = LocalContext.current - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - items.forEach { item -> - val color = - if (item.adverse) severityColor(item.severity) else timelineColor(item.timelineItem) - val link = sentryUiLinks.linkFor(item) - Row( - modifier = - Modifier.fillMaxWidth() - .clickable(enabled = link != null) { link?.let { onOpenUrl(context, it) } } - .padding(vertical = 7.dp), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - LiveFeedCategoryPill(item.category.label, color) - Text( - item.title(), - modifier = Modifier.weight(1f), - color = BuddyInk, - fontWeight = if (item.adverse) FontWeight.Bold else FontWeight.Normal, - ) - Text( - relativeTime(item.timestamp.time, nowMs), - color = BuddyMuted, - style = MaterialTheme.typography.labelMedium, - ) + Surface( + modifier = Modifier.fillMaxWidth(), + color = Color.Transparent, + shape = RoundedCornerShape(16.dp), + border = CardDefaults.outlinedCardBorder(), + ) { + Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp)) { + items.forEach { item -> + val color = + if (item.adverse) severityColor(item.severity) else timelineColor(item.timelineItem) + val link = sentryUiLinks.linkFor(item) + Row( + modifier = + Modifier.fillMaxWidth() + .clickable(enabled = link != null) { link?.let { onOpenUrl(context, it) } } + .padding(vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LiveFeedCategoryPill(item.category.label, color) + Text( + item.title(), + modifier = Modifier.weight(1f), + color = BuddyInk, + fontWeight = FontWeight.Normal, + ) + Text( + relativeTime(item.timestamp.time, nowMs), + color = BuddyMuted, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Normal, + ) + } } } } @@ -764,11 +1033,21 @@ private fun LiveFeedCategoryPill(label: String, color: Color) { modifier = Modifier.padding(horizontal = 9.dp, vertical = 5.dp), color = color, style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, + fontWeight = FontWeight.Normal, ) } } +private object EmptyAttentionArtIndex { + private var nextIndex = 0 + + fun next(): Int { + val index = nextIndex + nextIndex = (nextIndex + 1) % EMPTY_ATTENTION_ART_VARIANTS + return index + } +} + @Composable private fun SentryBuddyGlyph(tint: Color, modifier: Modifier = Modifier) { Icon( @@ -1293,6 +1572,8 @@ private val BuddyBubbleTouchPadding = 20.dp private val BuddyTransientTextWidth = 190.dp private val BuddyTransientTextHeight = 28.dp private const val LIVE_FEED_VISIBLE_ITEM_LIMIT = 7 +private const val EMPTY_ATTENTION_ART_VARIANTS = 10 +private const val ATTENTION_HEADER_FADE_DISTANCE_PX = 180f private const val ANALYSIS_POLL_INTERVAL_MS = 1000L private const val ANALYSIS_TIMEOUT_MS = 30_000L