mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Add mobile agent Live Updates
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -4,4 +4,12 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<application>
|
||||
<activity
|
||||
android:name=".AgentLiveUpdateDemoActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="true"
|
||||
android:noHistory="true"
|
||||
android:theme="@android:style/Theme.NoDisplay" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
|
||||
/**
|
||||
* Debug-only entry point for previewing the Android system Live Update.
|
||||
*
|
||||
* Launch the exported activity explicitly from a development device after
|
||||
* granting notification permission. Release builds do not include it.
|
||||
*/
|
||||
class AgentLiveUpdateDemoActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
AgentLiveUpdateManager(this).show(
|
||||
AgentLiveUpdatePayload(
|
||||
title = intent.getStringExtra(EXTRA_TITLE) ?: "Codex is working",
|
||||
body = intent.getStringExtra(EXTRA_BODY) ?: "In #agents",
|
||||
activeCount = intent.getIntExtra(EXTRA_ACTIVE_COUNT, 1),
|
||||
startedAtMillis = System.currentTimeMillis() - DEMO_ELAPSED_MILLIS,
|
||||
timeoutAfterMillis = DEMO_TIMEOUT_MILLIS,
|
||||
channelId = "agents-demo",
|
||||
messageId = null,
|
||||
),
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val EXTRA_TITLE = "title"
|
||||
private const val EXTRA_BODY = "body"
|
||||
private const val EXTRA_ACTIVE_COUNT = "activeCount"
|
||||
private const val DEMO_ELAPSED_MILLIS = 32_000L
|
||||
private const val DEMO_TIMEOUT_MILLIS = 10 * 60_000L
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.POST_PROMOTED_NOTIFICATIONS" />
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
|
||||
internal class AgentLiveUpdateManager(
|
||||
private val activity: Activity,
|
||||
) {
|
||||
private val notificationManager =
|
||||
activity.getSystemService(NotificationManager::class.java)
|
||||
private val preferences =
|
||||
activity.getSharedPreferences(PREFERENCES_NAME, Activity.MODE_PRIVATE)
|
||||
|
||||
private var pendingPayload: AgentLiveUpdatePayload? = null
|
||||
private var permissionRequestInFlight = false
|
||||
|
||||
fun show(payload: AgentLiveUpdatePayload): Boolean {
|
||||
pendingPayload = payload
|
||||
if (!canPostNotifications()) {
|
||||
requestNotificationPermissionOnce()
|
||||
return false
|
||||
}
|
||||
|
||||
post(payload)
|
||||
pendingPayload = null
|
||||
return true
|
||||
}
|
||||
|
||||
fun dismiss() {
|
||||
pendingPayload = null
|
||||
notificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID)
|
||||
}
|
||||
|
||||
fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
if (requestCode != NOTIFICATION_PERMISSION_REQUEST_CODE) return
|
||||
permissionRequestInFlight = false
|
||||
if (grantResults.firstOrNull() != PackageManager.PERMISSION_GRANTED) {
|
||||
pendingPayload = null
|
||||
return
|
||||
}
|
||||
|
||||
pendingPayload?.let(::post)
|
||||
pendingPayload = null
|
||||
}
|
||||
|
||||
private fun canPostNotifications(): Boolean =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
|
||||
activity.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
|
||||
private fun requestNotificationPermissionOnce() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
|
||||
if (permissionRequestInFlight) return
|
||||
if (preferences.getBoolean(PERMISSION_REQUESTED_KEY, false)) return
|
||||
|
||||
permissionRequestInFlight = true
|
||||
preferences.edit().putBoolean(PERMISSION_REQUESTED_KEY, true).apply()
|
||||
activity.requestPermissions(
|
||||
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
|
||||
NOTIFICATION_PERMISSION_REQUEST_CODE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun post(payload: AgentLiveUpdatePayload) {
|
||||
createChannel()
|
||||
|
||||
val publicNotification = newNotificationBuilder()
|
||||
.setSmallIcon(R.drawable.ic_agent_live_update)
|
||||
.setContentTitle(
|
||||
activity.resources.getQuantityString(
|
||||
R.plurals.agent_activity_public_title,
|
||||
payload.activeCount,
|
||||
payload.activeCount,
|
||||
),
|
||||
)
|
||||
.setContentText(activity.getString(R.string.agent_activity_public_body))
|
||||
.setContentIntent(contentIntent(payload))
|
||||
.setCategory(Notification.CATEGORY_PROGRESS)
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(payload.startedAtMillis)
|
||||
.setUsesChronometer(true)
|
||||
.build()
|
||||
|
||||
val notification = newNotificationBuilder()
|
||||
.setSmallIcon(R.drawable.ic_agent_live_update)
|
||||
.setContentTitle(payload.title)
|
||||
.setContentText(payload.body)
|
||||
.setStyle(Notification.BigTextStyle().bigText(payload.body))
|
||||
.setContentIntent(contentIntent(payload))
|
||||
.setPublicVersion(publicNotification)
|
||||
.setCategory(Notification.CATEGORY_PROGRESS)
|
||||
.setVisibility(Notification.VISIBILITY_PRIVATE)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(payload.startedAtMillis)
|
||||
.setUsesChronometer(true)
|
||||
.addExtras(
|
||||
Bundle().apply {
|
||||
putBoolean(REQUEST_PROMOTED_ONGOING_EXTRA, true)
|
||||
},
|
||||
)
|
||||
.apply {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
setTimeoutAfter(payload.timeoutAfterMillis)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
notificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun newNotificationBuilder(): Notification.Builder =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(activity, CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(activity)
|
||||
}
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
activity.getString(R.string.agent_activity_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = activity.getString(R.string.agent_activity_channel_description)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
enableVibration(false)
|
||||
setSound(null, null)
|
||||
setShowBadge(false)
|
||||
}
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun contentIntent(payload: AgentLiveUpdatePayload): PendingIntent {
|
||||
val messageId = payload.messageId
|
||||
val intent = if (messageId != null) {
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setClass(activity, MainActivity::class.java)
|
||||
data = Uri.Builder()
|
||||
.scheme("buzz")
|
||||
.authority("message")
|
||||
.appendQueryParameter("channel", payload.channelId)
|
||||
.appendQueryParameter("id", messageId)
|
||||
.build()
|
||||
}
|
||||
} else {
|
||||
activity.packageManager.getLaunchIntentForPackage(activity.packageName)
|
||||
?: Intent(activity, MainActivity::class.java)
|
||||
}.apply {
|
||||
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
return PendingIntent.getActivity(
|
||||
activity,
|
||||
CONTENT_INTENT_REQUEST_CODE,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "buzz_agent_live_updates"
|
||||
private const val NOTIFICATION_TAG = "buzz_agent_live_update"
|
||||
private const val NOTIFICATION_ID = 24_200
|
||||
private const val NOTIFICATION_PERMISSION_REQUEST_CODE = 24_201
|
||||
private const val CONTENT_INTENT_REQUEST_CODE = 24_202
|
||||
private const val PREFERENCES_NAME = "buzz_agent_live_updates"
|
||||
private const val PERMISSION_REQUESTED_KEY = "notification_permission_requested"
|
||||
private const val REQUEST_PROMOTED_ONGOING_EXTRA = "android.requestPromotedOngoing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
internal data class AgentLiveUpdatePayload(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val activeCount: Int,
|
||||
val startedAtMillis: Long,
|
||||
val timeoutAfterMillis: Long,
|
||||
val channelId: String,
|
||||
val messageId: String?,
|
||||
) {
|
||||
companion object {
|
||||
fun from(arguments: Any?): AgentLiveUpdatePayload? {
|
||||
val values = arguments as? Map<*, *> ?: return null
|
||||
val title = (values["title"] as? String)?.takeIf { it.isNotBlank() } ?: return null
|
||||
val body = (values["body"] as? String)?.takeIf { it.isNotBlank() } ?: return null
|
||||
val channelId =
|
||||
(values["channelId"] as? String)?.takeIf { it.isNotBlank() } ?: return null
|
||||
val activeCount = (values["activeCount"] as? Number)?.toInt() ?: return null
|
||||
val startedAtMillis =
|
||||
(values["startedAtMillis"] as? Number)?.toLong() ?: return null
|
||||
val timeoutAfterMillis =
|
||||
(values["timeoutAfterMillis"] as? Number)?.toLong() ?: return null
|
||||
|
||||
if (activeCount < 1 || startedAtMillis < 0 || timeoutAfterMillis < 1) return null
|
||||
|
||||
return AgentLiveUpdatePayload(
|
||||
title = title,
|
||||
body = body,
|
||||
activeCount = activeCount,
|
||||
startedAtMillis = startedAtMillis,
|
||||
timeoutAfterMillis = timeoutAfterMillis,
|
||||
channelId = channelId,
|
||||
messageId = (values["messageId"] as? String)?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,10 +78,36 @@ internal object AndroidImageProcessor {
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var mediaUploadChannel: MethodChannel? = null
|
||||
private var agentLiveUpdatesChannel: MethodChannel? = null
|
||||
private var agentLiveUpdateManager: AgentLiveUpdateManager? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
agentLiveUpdateManager = AgentLiveUpdateManager(this)
|
||||
agentLiveUpdatesChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
AGENT_LIVE_UPDATES_CHANNEL,
|
||||
).also { channel ->
|
||||
channel.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
SHOW_AGENT_LIVE_UPDATE_METHOD -> {
|
||||
val payload = AgentLiveUpdatePayload.from(call.arguments)
|
||||
if (payload == null) {
|
||||
invalidArguments(result, "Expected agent activity details.")
|
||||
return@setMethodCallHandler
|
||||
}
|
||||
result.success(agentLiveUpdateManager?.show(payload) == true)
|
||||
}
|
||||
DISMISS_AGENT_LIVE_UPDATE_METHOD -> {
|
||||
agentLiveUpdateManager?.dismiss()
|
||||
result.success(null)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mediaUploadChannel = MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
MEDIA_UPLOAD_CHANNEL,
|
||||
@@ -103,6 +129,23 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
agentLiveUpdatesChannel?.setMethodCallHandler(null)
|
||||
agentLiveUpdatesChannel = null
|
||||
mediaUploadChannel?.setMethodCallHandler(null)
|
||||
mediaUploadChannel = null
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
agentLiveUpdateManager?.onRequestPermissionsResult(requestCode, grantResults)
|
||||
}
|
||||
|
||||
private fun handleSanitizeImageForUpload(
|
||||
arguments: Any?,
|
||||
result: MethodChannel.Result,
|
||||
@@ -280,6 +323,9 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val AGENT_LIVE_UPDATES_CHANNEL = "buzz/agent_live_updates"
|
||||
private const val SHOW_AGENT_LIVE_UPDATE_METHOD = "show"
|
||||
private const val DISMISS_AGENT_LIVE_UPDATE_METHOD = "dismiss"
|
||||
private const val MEDIA_UPLOAD_CHANNEL = "buzz/media_upload"
|
||||
private const val SANITIZE_IMAGE_FOR_UPLOAD_METHOD = "sanitizeImageForUpload"
|
||||
private const val TRANSCODE_IMAGE_TO_JPEG_METHOD = "transcodeImageToJpeg"
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M9,2h2v2h2V2h2v2h1c1.66,0 3,1.34 3,3v10c0,1.66 -1.34,3 -3,3H8c-1.66,0 -3,-1.34 -3,-3V7c0,-1.66 1.34,-3 3,-3h1V2zM9,9a1.5,1.5 0,1 0,0 3,1.5 1.5,0 0,0 0,-3zM15,9a1.5,1.5 0,1 0,0 3,1.5 1.5,0 0,0 0,-3zM8,15v2h8v-2H8z" />
|
||||
</vector>
|
||||
9
mobile/android/app/src/main/res/values/strings.xml
Normal file
9
mobile/android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<resources>
|
||||
<string name="agent_activity_channel_name">Agent activity</string>
|
||||
<string name="agent_activity_channel_description">Live progress for running Buzz agents</string>
|
||||
<string name="agent_activity_public_body">Open Buzz to view progress</string>
|
||||
<plurals name="agent_activity_public_title">
|
||||
<item quantity="one">Agent is working</item>
|
||||
<item quantity="other">%d agents are working</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
@@ -0,0 +1,48 @@
|
||||
package xyz.block.buzz.mobile
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class AgentLiveUpdatePayloadTest {
|
||||
@Test
|
||||
fun `parses Flutter method channel values`() {
|
||||
val payload = AgentLiveUpdatePayload.from(
|
||||
mapOf(
|
||||
"title" to "Agent is working",
|
||||
"body" to "Working in #agents",
|
||||
"activeCount" to 1,
|
||||
"startedAtMillis" to 1_722_081_600_000L,
|
||||
"timeoutAfterMillis" to 30_000,
|
||||
"channelId" to "channel-1",
|
||||
"messageId" to "message-1",
|
||||
),
|
||||
)
|
||||
|
||||
requireNotNull(payload)
|
||||
assertEquals("Agent is working", payload.title)
|
||||
assertEquals("Working in #agents", payload.body)
|
||||
assertEquals(1, payload.activeCount)
|
||||
assertEquals(1_722_081_600_000L, payload.startedAtMillis)
|
||||
assertEquals(30_000L, payload.timeoutAfterMillis)
|
||||
assertEquals("channel-1", payload.channelId)
|
||||
assertEquals("message-1", payload.messageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects missing or invalid required values`() {
|
||||
assertNull(AgentLiveUpdatePayload.from(null))
|
||||
assertNull(
|
||||
AgentLiveUpdatePayload.from(
|
||||
mapOf(
|
||||
"title" to "",
|
||||
"body" to "Working in #agents",
|
||||
"activeCount" to 0,
|
||||
"startedAtMillis" to 1L,
|
||||
"timeoutAfterMillis" to 30_000,
|
||||
"channelId" to "channel-1",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
163
mobile/ios/AgentActivityWidget/AgentActivityWidget.swift
Normal file
163
mobile/ios/AgentActivityWidget/AgentActivityWidget.swift
Normal file
@@ -0,0 +1,163 @@
|
||||
import ActivityKit
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct AgentActivityWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
AgentActivityLiveActivity()
|
||||
}
|
||||
}
|
||||
|
||||
struct AgentActivityLiveActivity: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: AgentActivityAttributes.self) { context in
|
||||
AgentActivityLockScreenView(state: context.state)
|
||||
.activityBackgroundTint(.black)
|
||||
.activitySystemActionForegroundColor(.white)
|
||||
.widgetURL(URL(string: context.state.deepLink))
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading) {
|
||||
BuzzLogo(size: 36)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
Text(context.state.startedAt, style: .timer)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.padding(.trailing, 8)
|
||||
.frame(width: 100, height: 36, alignment: .trailing)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
AgentActivityTitle(state: context.state)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
AgentActivityBody(state: context.state)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 6)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
} compactLeading: {
|
||||
BuzzLogo(size: 22)
|
||||
} compactTrailing: {
|
||||
Text("\(context.state.activeAgentCount)")
|
||||
.font(.caption2.bold().monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
.accessibilityLabel(
|
||||
"\(context.state.activeAgentCount) active agents"
|
||||
)
|
||||
} minimal: {
|
||||
BuzzLogo(size: 22)
|
||||
}
|
||||
.keylineTint(.white)
|
||||
.widgetURL(URL(string: context.state.deepLink))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentActivityLockScreenView: View {
|
||||
let state: AgentActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
BuzzLogo(size: 36)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
AgentActivityTitle(state: state)
|
||||
.font(.headline)
|
||||
.lineLimit(2)
|
||||
AgentActivityBody(state: state)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.layoutPriority(1)
|
||||
Text(state.startedAt, style: .timer)
|
||||
.font(.caption.monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
.frame(minWidth: 48, minHeight: 36, alignment: .trailing)
|
||||
.layoutPriority(2)
|
||||
.accessibilityLabel("Agent activity elapsed time")
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(16)
|
||||
}
|
||||
}
|
||||
|
||||
private struct BuzzLogo: View {
|
||||
let size: CGFloat
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if let image = widgetSafeBuzzLogo {
|
||||
Image(uiImage: image)
|
||||
.renderingMode(.original)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.accessibilityLabel("Buzz agent activity")
|
||||
}
|
||||
}
|
||||
|
||||
private let widgetSafeBuzzLogo: UIImage? = {
|
||||
guard
|
||||
let imageURL = Bundle.main.url(
|
||||
forResource: "buzz-icon",
|
||||
withExtension: "png"
|
||||
),
|
||||
let sourceImage = UIImage(contentsOfFile: imageURL.path)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let size = CGSize(width: 36, height: 24)
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.opaque = false
|
||||
format.scale = 3
|
||||
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
|
||||
sourceImage.draw(in: CGRect(origin: .zero, size: size))
|
||||
}
|
||||
}()
|
||||
|
||||
private struct AgentActivityTitle: View {
|
||||
@Environment(\.redactionReasons) private var redactionReasons
|
||||
|
||||
let state: AgentActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
if redactionReasons.contains(.privacy) {
|
||||
Text(state.publicTitle)
|
||||
.unredacted()
|
||||
} else {
|
||||
Text(state.title)
|
||||
.privacySensitive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentActivityBody: View {
|
||||
@Environment(\.redactionReasons) private var redactionReasons
|
||||
|
||||
let state: AgentActivityAttributes.ContentState
|
||||
|
||||
var body: some View {
|
||||
if redactionReasons.contains(.privacy) {
|
||||
Text(state.publicBody)
|
||||
.unredacted()
|
||||
} else {
|
||||
Text(state.body)
|
||||
.privacySensitive()
|
||||
}
|
||||
}
|
||||
}
|
||||
31
mobile/ios/AgentActivityWidget/Info.plist
Normal file
31
mobile/ios/AgentActivityWidget/Info.plist
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Agent activity</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
5
mobile/ios/Flutter/AgentActivityWidgetDebug.xcconfig
Normal file
5
mobile/ios/Flutter/AgentActivityWidgetDebug.xcconfig
Normal file
@@ -0,0 +1,5 @@
|
||||
#include "Generated.xcconfig"
|
||||
BUNDLE_IDENTIFIER = com.buzz.buzzMobile
|
||||
#include? "WorktreeOverrides.xcconfig"
|
||||
#include? "AppOverrides.xcconfig"
|
||||
AGENT_ACTIVITY_WIDGET_BUNDLE_IDENTIFIER = $(BUNDLE_IDENTIFIER).AgentActivityWidget
|
||||
4
mobile/ios/Flutter/AgentActivityWidgetRelease.xcconfig
Normal file
4
mobile/ios/Flutter/AgentActivityWidgetRelease.xcconfig
Normal file
@@ -0,0 +1,4 @@
|
||||
#include "Generated.xcconfig"
|
||||
BUNDLE_IDENTIFIER = com.buzz.buzzMobile
|
||||
#include? "AppOverrides.xcconfig"
|
||||
AGENT_ACTIVITY_WIDGET_BUNDLE_IDENTIFIER = $(BUNDLE_IDENTIFIER).AgentActivityWidget
|
||||
@@ -7,6 +7,15 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
1E3412A9C9CD466D92662100 /* AgentLiveActivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4ADD97AC451436D8722C8E7 /* AgentLiveActivityManager.swift */; };
|
||||
8677B8B044F24A9A956F1D82 /* AgentActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC79440C8E44CE8BC2BDA08 /* AgentActivityAttributes.swift */; };
|
||||
7ECB1A83E0BC4ED4989688A1 /* AgentActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6AC79440C8E44CE8BC2BDA08 /* AgentActivityAttributes.swift */; };
|
||||
A2AE87558B6248C4AE19710D /* AgentActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4382D7DC2F2B48EDAA65EA9A /* AgentActivityWidget.swift */; };
|
||||
E5B2C3D4E5F60718293A4B5C /* buzz-icon.png in Resources */ = {isa = PBXBuildFile; fileRef = D4A1B2C3D4E5F60718293A4B /* buzz-icon.png */; };
|
||||
B1D6C98B24A64405BAAB9971 /* AgentActivityWidgetExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6384613751364ADF967AC14B /* AgentActivityWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
BDA1B60B9B5342CFB1DE8652 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5ABE61D8BE4C4D73A8ED2178 /* ActivityKit.framework */; };
|
||||
A64FC0B69C834CE2929380D1 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8133E964C68417397346919 /* WidgetKit.framework */; };
|
||||
A7C10C2B92E04AF39A0413AE /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 20BFDF3787F04F4DA97C1238 /* SwiftUI.framework */; };
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; };
|
||||
@@ -23,6 +32,13 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
F64D4B7CDDA94B77B098B79A /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 018001C57D7645169210DC4F;
|
||||
remoteInfo = AgentActivityWidgetExtension;
|
||||
};
|
||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
||||
@@ -33,6 +49,17 @@
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
D93717A18DDF4D0EBB389661 /* Embed App Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
B1D6C98B24A64405BAAB9971 /* AgentActivityWidgetExtension.appex in Embed App Extensions */,
|
||||
);
|
||||
name = "Embed App Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -46,6 +73,17 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
B4ADD97AC451436D8722C8E7 /* AgentLiveActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentLiveActivityManager.swift; sourceTree = "<group>"; };
|
||||
6AC79440C8E44CE8BC2BDA08 /* AgentActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityAttributes.swift; sourceTree = "<group>"; };
|
||||
4382D7DC2F2B48EDAA65EA9A /* AgentActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityWidget.swift; sourceTree = "<group>"; };
|
||||
200E80220AC94B00A78481AD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
D4A1B2C3D4E5F60718293A4B /* buzz-icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = "buzz-icon.png"; path = "../assets/images/buzz-icon.png"; sourceTree = SOURCE_ROOT; };
|
||||
6384613751364ADF967AC14B /* AgentActivityWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AgentActivityWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
006B786FECCA45719A566360 /* AgentActivityWidgetDebug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = AgentActivityWidgetDebug.xcconfig; path = Flutter/AgentActivityWidgetDebug.xcconfig; sourceTree = "<group>"; };
|
||||
C90DB9EDB64C4428B725E26C /* AgentActivityWidgetRelease.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = AgentActivityWidgetRelease.xcconfig; path = Flutter/AgentActivityWidgetRelease.xcconfig; sourceTree = "<group>"; };
|
||||
5ABE61D8BE4C4D73A8ED2178 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
|
||||
F8133E964C68417397346919 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||
20BFDF3787F04F4DA97C1238 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||
@@ -76,6 +114,16 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
99B4689042B847D3A84BDA71 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
BDA1B60B9B5342CFB1DE8652 /* ActivityKit.framework in Frameworks */,
|
||||
A7C10C2B92E04AF39A0413AE /* SwiftUI.framework in Frameworks */,
|
||||
A64FC0B69C834CE2929380D1 /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3C28C6B702C81085E6F96F2A /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -95,6 +143,24 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
CDB583F6C31848EFA23A1CBE /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6AC79440C8E44CE8BC2BDA08 /* AgentActivityAttributes.swift */,
|
||||
);
|
||||
path = Shared;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5D5D501C06C94AE0A4D79DBE /* AgentActivityWidget */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4382D7DC2F2B48EDAA65EA9A /* AgentActivityWidget.swift */,
|
||||
D4A1B2C3D4E5F60718293A4B /* buzz-icon.png */,
|
||||
200E80220AC94B00A78481AD /* Info.plist */,
|
||||
);
|
||||
path = AgentActivityWidget;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -131,6 +197,8 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
006B786FECCA45719A566360 /* AgentActivityWidgetDebug.xcconfig */,
|
||||
C90DB9EDB64C4428B725E26C /* AgentActivityWidgetRelease.xcconfig */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
||||
@@ -143,6 +211,8 @@
|
||||
children = (
|
||||
9740EEB11CF90186004384FC /* Flutter */,
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
CDB583F6C31848EFA23A1CBE /* Shared */,
|
||||
5D5D501C06C94AE0A4D79DBE /* AgentActivityWidget */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
746EA2D40EB8F35E67C48311 /* Pods */,
|
||||
@@ -154,6 +224,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
||||
6384613751364ADF967AC14B /* AgentActivityWidgetExtension.appex */,
|
||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -169,6 +240,7 @@
|
||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
B4ADD97AC451436D8722C8E7 /* AgentLiveActivityManager.swift */,
|
||||
331C809A294A618700263BE5 /* MediaSanitizer.swift */,
|
||||
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
@@ -179,6 +251,9 @@
|
||||
D12A84F9A6072498822784CC /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5ABE61D8BE4C4D73A8ED2178 /* ActivityKit.framework */,
|
||||
20BFDF3787F04F4DA97C1238 /* SwiftUI.framework */,
|
||||
F8133E964C68417397346919 /* WidgetKit.framework */,
|
||||
8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */,
|
||||
CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
@@ -188,6 +263,23 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
018001C57D7645169210DC4F /* AgentActivityWidgetExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 48EA4A0A99104CCBAB26AD56 /* Build configuration list for PBXNativeTarget "AgentActivityWidgetExtension" */;
|
||||
buildPhases = (
|
||||
E1B79EF27CA64095B66CA4A6 /* Sources */,
|
||||
99B4689042B847D3A84BDA71 /* Frameworks */,
|
||||
BBFD7EBE4E8D42EE986ACF2B /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = AgentActivityWidgetExtension;
|
||||
productName = AgentActivityWidgetExtension;
|
||||
productReference = 6384613751364ADF967AC14B /* AgentActivityWidgetExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
@@ -217,12 +309,14 @@
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
D93717A18DDF4D0EBB389661 /* Embed App Extensions */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
4218BF159E304F8F8A642694 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Runner;
|
||||
productName = Runner;
|
||||
@@ -239,6 +333,9 @@
|
||||
LastUpgradeCheck = 1510;
|
||||
ORGANIZATIONNAME = "";
|
||||
TargetAttributes = {
|
||||
018001C57D7645169210DC4F = {
|
||||
CreatedOnToolsVersion = 26.0;
|
||||
};
|
||||
331C8080294A63A400263BE5 = {
|
||||
CreatedOnToolsVersion = 14.0;
|
||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
||||
@@ -263,12 +360,21 @@
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
97C146ED1CF9000F007C117D /* Runner */,
|
||||
018001C57D7645169210DC4F /* AgentActivityWidgetExtension */,
|
||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
BBFD7EBE4E8D42EE986ACF2B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E5B2C3D4E5F60718293A4B5C /* buzz-icon.png in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
331C807F294A63A400263BE5 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -387,6 +493,15 @@
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
E1B79EF27CA64095B66CA4A6 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7ECB1A83E0BC4ED4989688A1 /* AgentActivityAttributes.swift in Sources */,
|
||||
A2AE87558B6248C4AE19710D /* AgentActivityWidget.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
331C807D294A63A400263BE5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -400,6 +515,8 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
1E3412A9C9CD466D92662100 /* AgentLiveActivityManager.swift in Sources */,
|
||||
8677B8B044F24A9A956F1D82 /* AgentActivityAttributes.swift in Sources */,
|
||||
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
|
||||
@@ -409,6 +526,11 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
4218BF159E304F8F8A642694 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 018001C57D7645169210DC4F /* AgentActivityWidgetExtension */;
|
||||
targetProxy = F64D4B7CDDA94B77B098B79A /* PBXContainerItemProxy */;
|
||||
};
|
||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
||||
@@ -436,6 +558,87 @@
|
||||
/* End PBXVariantGroup section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
A81D53527B9B40EDAADBF6FD /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 006B786FECCA45719A566360 /* AgentActivityWidgetDebug.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = AgentActivityWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(AGENT_ACTIVITY_WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
0C4CABAC5A464F1A94E100F2 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = C90DB9EDB64C4428B725E26C /* AgentActivityWidgetRelease.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = AgentActivityWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(AGENT_ACTIVITY_WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
29C29D0ABFB5485A88E0BC4B /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = C90DB9EDB64C4428B725E26C /* AgentActivityWidgetRelease.xcconfig */;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = "";
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = AgentActivityWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "$(AGENT_ACTIVITY_WIDGET_BUNDLE_IDENTIFIER)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = 1;
|
||||
};
|
||||
name = Profile;
|
||||
};
|
||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -724,6 +927,16 @@
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
48EA4A0A99104CCBAB26AD56 /* Build configuration list for PBXNativeTarget "AgentActivityWidgetExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
A81D53527B9B40EDAADBF6FD /* Debug */,
|
||||
0C4CABAC5A464F1A94E100F2 /* Release */,
|
||||
29C29D0ABFB5485A88E0BC4B /* Profile */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
167
mobile/ios/Runner/AgentLiveActivityManager.swift
Normal file
167
mobile/ios/Runner/AgentLiveActivityManager.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
struct AgentLiveActivityPayload: Equatable {
|
||||
let title: String
|
||||
let body: String
|
||||
let activeCount: Int
|
||||
let startedAtMillis: Int64
|
||||
let lastActivityAtMillis: Int64
|
||||
let timeoutAfterMillis: Int64
|
||||
let channelId: String
|
||||
let messageId: String?
|
||||
|
||||
var contentState: AgentActivityAttributes.ContentState {
|
||||
AgentActivityAttributes.ContentState(
|
||||
title: title,
|
||||
body: body,
|
||||
activeAgentCount: activeCount,
|
||||
startedAtMillis: startedAtMillis,
|
||||
deepLink: deepLink
|
||||
)
|
||||
}
|
||||
|
||||
var staleDate: Date {
|
||||
Date(
|
||||
timeIntervalSince1970:
|
||||
TimeInterval(lastActivityAtMillis + timeoutAfterMillis) / 1_000
|
||||
)
|
||||
}
|
||||
|
||||
var deepLink: String {
|
||||
guard let messageId else {
|
||||
return "buzz://"
|
||||
}
|
||||
var components = URLComponents()
|
||||
components.scheme = "buzz"
|
||||
components.host = "message"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "channel", value: channelId),
|
||||
URLQueryItem(name: "id", value: messageId),
|
||||
]
|
||||
return components.string ?? "buzz://"
|
||||
}
|
||||
|
||||
static func from(arguments: Any?) -> AgentLiveActivityPayload? {
|
||||
guard let values = arguments as? [String: Any] else {
|
||||
return nil
|
||||
}
|
||||
guard
|
||||
let title = nonEmptyString(values["title"]),
|
||||
let body = nonEmptyString(values["body"]),
|
||||
let channelId = nonEmptyString(values["channelId"]),
|
||||
let activeCount = number(values["activeCount"])?.intValue,
|
||||
let startedAtMillis = number(values["startedAtMillis"])?.int64Value,
|
||||
let lastActivityAtMillis = number(values["lastActivityAtMillis"])?.int64Value,
|
||||
let timeoutAfterMillis = number(values["timeoutAfterMillis"])?.int64Value,
|
||||
activeCount > 0,
|
||||
startedAtMillis >= 0,
|
||||
lastActivityAtMillis >= startedAtMillis,
|
||||
timeoutAfterMillis > 0
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AgentLiveActivityPayload(
|
||||
title: title,
|
||||
body: body,
|
||||
activeCount: activeCount,
|
||||
startedAtMillis: startedAtMillis,
|
||||
lastActivityAtMillis: lastActivityAtMillis,
|
||||
timeoutAfterMillis: timeoutAfterMillis,
|
||||
channelId: channelId,
|
||||
messageId: nonEmptyString(values["messageId"])
|
||||
)
|
||||
}
|
||||
|
||||
private static func nonEmptyString(_ value: Any?) -> String? {
|
||||
guard let value = value as? String,
|
||||
!value.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private static func number(_ value: Any?) -> NSNumber? {
|
||||
value as? NSNumber
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 16.1, *)
|
||||
enum AgentLiveActivityManager {
|
||||
static func show(_ payload: AgentLiveActivityPayload) async throws -> Bool {
|
||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else {
|
||||
return false
|
||||
}
|
||||
|
||||
let activities = Activity<AgentActivityAttributes>.activities
|
||||
if let current = activities.first {
|
||||
await update(current, with: payload)
|
||||
for duplicate in activities.dropFirst() {
|
||||
await end(duplicate)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
let appIsActive = await MainActor.run {
|
||||
UIApplication.shared.applicationState == .active
|
||||
}
|
||||
guard appIsActive else {
|
||||
return false
|
||||
}
|
||||
|
||||
let attributes = AgentActivityAttributes(source: "agent-activity")
|
||||
if #available(iOS 16.2, *) {
|
||||
let content = ActivityContent(
|
||||
state: payload.contentState,
|
||||
staleDate: payload.staleDate
|
||||
)
|
||||
_ = try Activity.request(
|
||||
attributes: attributes,
|
||||
content: content,
|
||||
pushType: nil
|
||||
)
|
||||
} else {
|
||||
_ = try Activity.request(
|
||||
attributes: attributes,
|
||||
contentState: payload.contentState,
|
||||
pushType: nil
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
static func dismiss() async {
|
||||
for activity in Activity<AgentActivityAttributes>.activities {
|
||||
await end(activity)
|
||||
}
|
||||
}
|
||||
|
||||
private static func update(
|
||||
_ activity: Activity<AgentActivityAttributes>,
|
||||
with payload: AgentLiveActivityPayload
|
||||
) async {
|
||||
if #available(iOS 16.2, *) {
|
||||
await activity.update(
|
||||
ActivityContent(
|
||||
state: payload.contentState,
|
||||
staleDate: payload.staleDate
|
||||
)
|
||||
)
|
||||
} else {
|
||||
await activity.update(using: payload.contentState)
|
||||
}
|
||||
}
|
||||
|
||||
private static func end(
|
||||
_ activity: Activity<AgentActivityAttributes>
|
||||
) async {
|
||||
if #available(iOS 16.2, *) {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
} else {
|
||||
await activity.end(using: nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,59 @@ import UserNotifications
|
||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||
private var mediaUploadChannel: FlutterMethodChannel?
|
||||
private var qrScannerChannel: FlutterMethodChannel?
|
||||
private var agentLiveUpdateChannel: FlutterMethodChannel?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
#if targetEnvironment(simulator)
|
||||
if !ProcessInfo.processInfo.arguments.contains("--agent-live-activity-demo") {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
|
||||
}
|
||||
#else
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.badge]) { _, _ in }
|
||||
#endif
|
||||
let didFinish = super.application(
|
||||
application,
|
||||
didFinishLaunchingWithOptions: launchOptions
|
||||
)
|
||||
#if targetEnvironment(simulator)
|
||||
startAgentLiveActivityDemoIfRequested()
|
||||
#endif
|
||||
return didFinish
|
||||
}
|
||||
|
||||
#if targetEnvironment(simulator)
|
||||
private func startAgentLiveActivityDemoIfRequested() {
|
||||
guard
|
||||
ProcessInfo.processInfo.arguments.contains("--agent-live-activity-demo"),
|
||||
#available(iOS 16.1, *)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
let now = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
let payload = AgentLiveActivityPayload(
|
||||
title: "Codex and Claude are working",
|
||||
body: "In #agents · Adding iOS Live Activities",
|
||||
activeCount: 2,
|
||||
startedAtMillis: now - 32_000,
|
||||
lastActivityAtMillis: now,
|
||||
timeoutAfterMillis: 10 * 60_000,
|
||||
channelId: "agents-demo",
|
||||
messageId: "agent-live-activity-demo"
|
||||
)
|
||||
Task {
|
||||
await AgentLiveActivityManager.dismiss()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
_ = try? await AgentLiveActivityManager.show(payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
mediaUploadChannel = FlutterMethodChannel(
|
||||
@@ -32,6 +76,64 @@ import UserNotifications
|
||||
qrScannerChannel?.setMethodCallHandler { call, result in
|
||||
Self.handleQrScannerMethodCall(call, result: result)
|
||||
}
|
||||
agentLiveUpdateChannel = FlutterMethodChannel(
|
||||
name: "buzz/agent_live_updates",
|
||||
binaryMessenger: engineBridge.applicationRegistrar.messenger()
|
||||
)
|
||||
agentLiveUpdateChannel?.setMethodCallHandler { call, result in
|
||||
Self.handleAgentLiveUpdateMethodCall(call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleAgentLiveUpdateMethodCall(
|
||||
_ call: FlutterMethodCall,
|
||||
result: @escaping FlutterResult
|
||||
) {
|
||||
guard #available(iOS 16.1, *) else {
|
||||
result(false)
|
||||
return
|
||||
}
|
||||
|
||||
switch call.method {
|
||||
case "show":
|
||||
guard let payload = AgentLiveActivityPayload.from(arguments: call.arguments) else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "invalid_arguments",
|
||||
message: "Expected valid agent activity details.",
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
Task {
|
||||
do {
|
||||
let didShow = try await AgentLiveActivityManager.show(payload)
|
||||
await MainActor.run {
|
||||
result(didShow)
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "agent_live_activity_failed",
|
||||
message: "Unable to show agent activity.",
|
||||
details: error.localizedDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
case "dismiss":
|
||||
Task {
|
||||
await AgentLiveActivityManager.dismiss()
|
||||
await MainActor.run {
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private static func handleQrScannerMethodCall(
|
||||
@@ -231,10 +333,12 @@ import UserNotifications
|
||||
let sourceURL = URL(fileURLWithPath: sourcePath)
|
||||
let asset = AVURLAsset(url: sourceURL)
|
||||
|
||||
guard let exportSession = AVAssetExportSession(
|
||||
asset: asset,
|
||||
presetName: AVAssetExportPresetPassthrough
|
||||
) else {
|
||||
guard
|
||||
let exportSession = AVAssetExportSession(
|
||||
asset: asset,
|
||||
presetName: AVAssetExportPresetPassthrough
|
||||
)
|
||||
else {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "transcode_failed",
|
||||
@@ -259,7 +363,8 @@ import UserNotifications
|
||||
case .completed:
|
||||
result(outputURL.path)
|
||||
default:
|
||||
let errorMessage = exportSession.error?.localizedDescription
|
||||
let errorMessage =
|
||||
exportSession.error?.localizedDescription
|
||||
?? "Video transcoding failed with status \(exportSession.status.rawValue)."
|
||||
result(
|
||||
FlutterError(
|
||||
|
||||
@@ -47,6 +47,8 @@
|
||||
<string>Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<string>Buzz needs photo library access so you can attach images to messages.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -6,6 +6,44 @@ import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
func testAgentLiveActivityPayloadParsesFlutterArguments() throws {
|
||||
let payload = try XCTUnwrap(
|
||||
AgentLiveActivityPayload.from(
|
||||
arguments: [
|
||||
"title": "Codex and Maya are working",
|
||||
"body": "Across #agents and #design",
|
||||
"activeCount": 2,
|
||||
"startedAtMillis": 1_785_152_800_000,
|
||||
"lastActivityAtMillis": 1_785_152_810_000,
|
||||
"timeoutAfterMillis": 30_000,
|
||||
"channelId": "channel-2",
|
||||
"messageId": "message-2",
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
XCTAssertEqual(payload.activeCount, 2)
|
||||
XCTAssertEqual(
|
||||
payload.deepLink,
|
||||
"buzz://message?channel=channel-2&id=message-2"
|
||||
)
|
||||
XCTAssertEqual(payload.contentState.publicTitle, "2 agents are working")
|
||||
XCTAssertEqual(payload.contentState.publicBody, "Open Buzz to view progress")
|
||||
}
|
||||
|
||||
func testAgentLiveActivityPayloadRejectsMissingTimingData() {
|
||||
XCTAssertNil(
|
||||
AgentLiveActivityPayload.from(
|
||||
arguments: [
|
||||
"title": "Codex is working",
|
||||
"body": "In #agents",
|
||||
"activeCount": 1,
|
||||
"channelId": "channel-1",
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func testDynamicIslandQrScannerRecognizesTallSafeAreas() {
|
||||
for safeAreaTopInset in [51, 59, 62] {
|
||||
XCTAssertTrue(
|
||||
|
||||
28
mobile/ios/Shared/AgentActivityAttributes.swift
Normal file
28
mobile/ios/Shared/AgentActivityAttributes.swift
Normal file
@@ -0,0 +1,28 @@
|
||||
import ActivityKit
|
||||
import Foundation
|
||||
|
||||
struct AgentActivityAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
let title: String
|
||||
let body: String
|
||||
let activeAgentCount: Int
|
||||
let startedAtMillis: Int64
|
||||
let deepLink: String
|
||||
|
||||
var publicTitle: String {
|
||||
activeAgentCount == 1
|
||||
? "Agent is working"
|
||||
: "\(activeAgentCount) agents are working"
|
||||
}
|
||||
|
||||
var publicBody: String {
|
||||
"Open Buzz to view progress"
|
||||
}
|
||||
|
||||
var startedAt: Date {
|
||||
Date(timeIntervalSince1970: TimeInterval(startedAtMillis) / 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
let source: String
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_badge_plus/app_badge_plus.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -7,13 +9,17 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'features/channels/unread_badge/unread_badge_provider.dart';
|
||||
import 'features/home/home_page.dart';
|
||||
import 'features/pairing/pairing_page.dart';
|
||||
import 'features/channels/agent_activity/active_agent_turns.dart';
|
||||
import 'features/channels/agent_activity/agent_live_updates.dart';
|
||||
import 'features/channels/agent_activity/observer_subscription.dart';
|
||||
import 'features/channels/deep_link_dispatcher.dart';
|
||||
import 'features/profile/user_cache_provider.dart';
|
||||
import 'features/profile/user_status_cache_provider.dart';
|
||||
import 'features/profile/settings_profile_header.dart';
|
||||
import 'features/settings/settings_page.dart';
|
||||
import 'shared/auth/auth.dart';
|
||||
import 'shared/deeplink/pending_deep_link_provider.dart';
|
||||
import 'shared/notifications/agent_live_update_preferences.dart';
|
||||
import 'shared/relay/relay.dart';
|
||||
import 'shared/theme/theme.dart';
|
||||
|
||||
@@ -26,6 +32,21 @@ class App extends HookConsumerWidget {
|
||||
final accentIndex = ref.watch(accentProvider);
|
||||
final schemeName = ref.watch(schemeProvider);
|
||||
final authState = ref.watch(authProvider);
|
||||
final isAuthenticated = authState.value?.status == AuthStatus.authenticated;
|
||||
final agentLiveUpdatesEnabled = ref.watch(agentLiveUpdatesEnabledProvider);
|
||||
final appLifecycleState = isAuthenticated
|
||||
? ref.watch(appLifecycleProvider)
|
||||
: null;
|
||||
final agentLiveUpdate = isAuthenticated && agentLiveUpdatesEnabled
|
||||
? ref.watch(agentLiveUpdateContentProvider)
|
||||
: null;
|
||||
final activeAgentPubkeys = isAuthenticated
|
||||
? ref
|
||||
.watch(activeAgentTurnsProvider)
|
||||
.map((turn) => turn.agentPubkey)
|
||||
.toSet()
|
||||
.toList()
|
||||
: const <String>[];
|
||||
|
||||
final resolved = resolveSchemes(schemeName, themeMode);
|
||||
final lightScheme = applyAccent(resolved.light, accentIndex);
|
||||
@@ -48,10 +69,9 @@ class App extends HookConsumerWidget {
|
||||
|
||||
// Eagerly initialize websocket session and lifecycle observer when
|
||||
// authenticated. These providers connect and manage the websocket.
|
||||
if (authState.value?.status == AuthStatus.authenticated) {
|
||||
if (isAuthenticated) {
|
||||
ref.watch(relaySessionProvider);
|
||||
ref.watch(observerRelayProvider);
|
||||
ref.watch(appLifecycleProvider);
|
||||
ref.watch(userStatusCacheProvider);
|
||||
}
|
||||
|
||||
@@ -59,6 +79,16 @@ class App extends HookConsumerWidget {
|
||||
// cold-start link survives until the authenticated UI can dispatch it.
|
||||
ref.watch(pendingDeepLinkProvider);
|
||||
|
||||
useEffect(() {
|
||||
unawaited(syncAgentLiveUpdate(agentLiveUpdate));
|
||||
return null;
|
||||
}, [agentLiveUpdate, appLifecycleState]);
|
||||
|
||||
useEffect(() {
|
||||
ref.read(userCacheProvider.notifier).preload(activeAgentPubkeys);
|
||||
return null;
|
||||
}, [activeAgentPubkeys.join('|')]);
|
||||
|
||||
void applyBadge(UnreadBadgeState state) {
|
||||
if (state.highPriorityCount > 0) {
|
||||
AppBadgePlus.updateBadge(state.highPriorityCount);
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'observer_models.dart';
|
||||
import 'observer_subscription.dart';
|
||||
|
||||
@immutable
|
||||
class ActiveAgentTurn {
|
||||
final String agentPubkey;
|
||||
final String channelId;
|
||||
final String turnId;
|
||||
final DateTime startedAt;
|
||||
final DateTime lastActivityAt;
|
||||
final String? triggeringEventId;
|
||||
|
||||
const ActiveAgentTurn({
|
||||
required this.agentPubkey,
|
||||
required this.channelId,
|
||||
required this.turnId,
|
||||
required this.startedAt,
|
||||
required this.lastActivityAt,
|
||||
this.triggeringEventId,
|
||||
});
|
||||
|
||||
ActiveAgentTurn copyWith({required DateTime lastActivityAt}) =>
|
||||
ActiveAgentTurn(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: startedAt,
|
||||
lastActivityAt: lastActivityAt,
|
||||
triggeringEventId: triggeringEventId,
|
||||
);
|
||||
}
|
||||
|
||||
List<ActiveAgentTurn> reduceActiveAgentTurns(
|
||||
Map<String, List<ObserverFrame>> framesByAgent,
|
||||
) {
|
||||
final activeTurns = <ActiveAgentTurn>[];
|
||||
|
||||
for (final entry in framesByAgent.entries) {
|
||||
final agentPubkey = entry.key.toLowerCase();
|
||||
final frames = [...entry.value]..sort(_compareFrames);
|
||||
final turnsById = <String, ActiveAgentTurn>{};
|
||||
final terminalAtById = <String, DateTime>{};
|
||||
|
||||
for (final frame in frames) {
|
||||
final frameAt = _frameTimestamp(frame);
|
||||
switch (frame.kind) {
|
||||
case 'turn_started':
|
||||
final channelId = frame.channelId;
|
||||
if (channelId == null) continue;
|
||||
final turnId = frame.turnId ?? 'seq-${frame.seq}';
|
||||
final startedAt = _safeStartedAt(frame, frameAt);
|
||||
turnsById[turnId] = ActiveAgentTurn(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: startedAt,
|
||||
lastActivityAt: frameAt,
|
||||
triggeringEventId: _triggeringEventId(frame.payload),
|
||||
);
|
||||
case 'turn_completed':
|
||||
case 'turn_error':
|
||||
case 'agent_panic':
|
||||
final turnId = frame.turnId;
|
||||
if (turnId != null) {
|
||||
terminalAtById[turnId] = frameAt;
|
||||
turnsById.remove(turnId);
|
||||
continue;
|
||||
}
|
||||
final channelId = frame.channelId;
|
||||
if (channelId == null) continue;
|
||||
final matchingTurn = turnsById.values
|
||||
.where((turn) => turn.channelId == channelId)
|
||||
.firstOrNull;
|
||||
if (matchingTurn != null) {
|
||||
terminalAtById[matchingTurn.turnId] = frameAt;
|
||||
turnsById.remove(matchingTurn.turnId);
|
||||
}
|
||||
case 'acp_read':
|
||||
case 'acp_write':
|
||||
case 'turn_liveness':
|
||||
final turnId = frame.turnId;
|
||||
if (turnId == null) continue;
|
||||
final activeTurn = turnsById[turnId];
|
||||
if (activeTurn != null) {
|
||||
turnsById[turnId] = activeTurn.copyWith(lastActivityAt: frameAt);
|
||||
continue;
|
||||
}
|
||||
|
||||
final channelId = frame.channelId;
|
||||
if (channelId == null) continue;
|
||||
final terminalAt = terminalAtById[turnId];
|
||||
if (terminalAt != null && !frameAt.isAfter(terminalAt)) continue;
|
||||
turnsById[turnId] = ActiveAgentTurn(
|
||||
agentPubkey: agentPubkey,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: _safeStartedAt(frame, frameAt),
|
||||
lastActivityAt: frameAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
activeTurns.addAll(turnsById.values);
|
||||
}
|
||||
|
||||
activeTurns.sort((a, b) {
|
||||
final started = a.startedAt.compareTo(b.startedAt);
|
||||
if (started != 0) return started;
|
||||
final agent = a.agentPubkey.compareTo(b.agentPubkey);
|
||||
if (agent != 0) return agent;
|
||||
return a.turnId.compareTo(b.turnId);
|
||||
});
|
||||
return List.unmodifiable(activeTurns);
|
||||
}
|
||||
|
||||
final activeAgentTurnsProvider = Provider<List<ActiveAgentTurn>>((ref) {
|
||||
final observerState = ref.watch(observerRelayProvider);
|
||||
return reduceActiveAgentTurns(observerState.framesByAgent);
|
||||
});
|
||||
|
||||
DateTime _frameTimestamp(ObserverFrame frame) =>
|
||||
DateTime.tryParse(frame.timestamp) ??
|
||||
DateTime.fromMillisecondsSinceEpoch(frame.seq);
|
||||
|
||||
DateTime _safeStartedAt(ObserverFrame frame, DateTime frameAt) {
|
||||
final startedAt = DateTime.tryParse(frame.startedAt ?? '');
|
||||
if (startedAt == null || startedAt.isAfter(frameAt)) return frameAt;
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
String? _triggeringEventId(dynamic payload) {
|
||||
if (payload is! Map) return null;
|
||||
final eventIds = payload['triggeringEventIds'];
|
||||
if (eventIds is! List) return null;
|
||||
for (final eventId in eventIds) {
|
||||
if (eventId is String && eventId.isNotEmpty) return eventId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
int _compareFrames(ObserverFrame a, ObserverFrame b) {
|
||||
final timestamp = _frameTimestamp(a).compareTo(_frameTimestamp(b));
|
||||
return timestamp != 0 ? timestamp : a.seq.compareTo(b.seq);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../../shared/utils/string_utils.dart';
|
||||
import '../../profile/user_cache_provider.dart';
|
||||
import '../channels_provider.dart';
|
||||
import 'active_agent_turns.dart';
|
||||
|
||||
const _agentLiveUpdatesChannel = MethodChannel('buzz/agent_live_updates');
|
||||
const _notificationTimeout = Duration(seconds: 30);
|
||||
|
||||
@immutable
|
||||
class AgentLiveUpdateContent {
|
||||
final String title;
|
||||
final String body;
|
||||
final int activeAgentCount;
|
||||
final DateTime startedAt;
|
||||
final DateTime lastActivityAt;
|
||||
final String channelId;
|
||||
final String? messageId;
|
||||
|
||||
const AgentLiveUpdateContent({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.activeAgentCount,
|
||||
required this.startedAt,
|
||||
required this.lastActivityAt,
|
||||
required this.channelId,
|
||||
this.messageId,
|
||||
});
|
||||
|
||||
Map<String, Object> toPlatformArguments() => {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'activeCount': activeAgentCount,
|
||||
'startedAtMillis': startedAt.millisecondsSinceEpoch,
|
||||
'lastActivityAtMillis': lastActivityAt.millisecondsSinceEpoch,
|
||||
'timeoutAfterMillis': _notificationTimeout.inMilliseconds,
|
||||
'channelId': channelId,
|
||||
'messageId': ?messageId,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is AgentLiveUpdateContent &&
|
||||
title == other.title &&
|
||||
body == other.body &&
|
||||
activeAgentCount == other.activeAgentCount &&
|
||||
startedAt == other.startedAt &&
|
||||
lastActivityAt == other.lastActivityAt &&
|
||||
channelId == other.channelId &&
|
||||
messageId == other.messageId;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
title,
|
||||
body,
|
||||
activeAgentCount,
|
||||
startedAt,
|
||||
lastActivityAt,
|
||||
channelId,
|
||||
messageId,
|
||||
);
|
||||
}
|
||||
|
||||
AgentLiveUpdateContent? buildAgentLiveUpdateContent(
|
||||
List<ActiveAgentTurn> turns,
|
||||
Map<String, String> channelNames, [
|
||||
Map<String, String> agentNames = const {},
|
||||
]) {
|
||||
if (turns.isEmpty) return null;
|
||||
|
||||
final agentPubkeys = turns.map((turn) => turn.agentPubkey).toSet().toList()
|
||||
..sort(
|
||||
(a, b) =>
|
||||
_agentLabel(a, agentNames).compareTo(_agentLabel(b, agentNames)),
|
||||
);
|
||||
final agentLabels = agentPubkeys
|
||||
.map((pubkey) => _agentLabel(pubkey, agentNames))
|
||||
.toList();
|
||||
final agentCount = agentPubkeys.length;
|
||||
final activeChannelIds = turns.map((turn) => turn.channelId).toSet();
|
||||
final activeChannelLabels =
|
||||
activeChannelIds
|
||||
.map((id) => '#${channelNames[id] ?? 'channel'}')
|
||||
.toSet()
|
||||
.toList()
|
||||
..sort();
|
||||
final startedAt = turns
|
||||
.map((turn) => turn.startedAt)
|
||||
.reduce((earliest, value) => value.isBefore(earliest) ? value : earliest);
|
||||
final lastActivityAt = turns
|
||||
.map((turn) => turn.lastActivityAt)
|
||||
.reduce((latest, value) => value.isAfter(latest) ? value : latest);
|
||||
final tapTarget = [...turns]
|
||||
..sort((a, b) => b.lastActivityAt.compareTo(a.lastActivityAt));
|
||||
final targetWithMessage = tapTarget
|
||||
.where((turn) => turn.triggeringEventId != null)
|
||||
.firstOrNull;
|
||||
final target = targetWithMessage ?? tapTarget.first;
|
||||
|
||||
return AgentLiveUpdateContent(
|
||||
title: _agentTitle(agentLabels),
|
||||
body: _channelSummary(activeChannelLabels),
|
||||
activeAgentCount: agentCount,
|
||||
startedAt: startedAt,
|
||||
lastActivityAt: lastActivityAt,
|
||||
channelId: target.channelId,
|
||||
messageId: target.triggeringEventId,
|
||||
);
|
||||
}
|
||||
|
||||
final agentLiveUpdateContentProvider = Provider<AgentLiveUpdateContent?>((ref) {
|
||||
final turns = ref.watch(activeAgentTurnsProvider);
|
||||
final channels = ref.watch(channelsProvider).value ?? const [];
|
||||
final channelNames = {
|
||||
for (final channel in channels) channel.id: channel.name,
|
||||
};
|
||||
final profiles = ref.watch(userCacheProvider);
|
||||
final agentNames = {
|
||||
for (final turn in turns)
|
||||
if (profiles[turn.agentPubkey.toLowerCase()] case final profile?)
|
||||
turn.agentPubkey: profile.label,
|
||||
};
|
||||
return buildAgentLiveUpdateContent(turns, channelNames, agentNames);
|
||||
});
|
||||
|
||||
String _agentLabel(String pubkey, Map<String, String> agentNames) {
|
||||
final name = agentNames[pubkey]?.trim();
|
||||
return name?.isNotEmpty == true ? name! : shortPubkey(pubkey);
|
||||
}
|
||||
|
||||
String _agentTitle(List<String> agentLabels) => switch (agentLabels.length) {
|
||||
1 => '${agentLabels.first} is working',
|
||||
2 => '${agentLabels.first} and ${agentLabels.last} are working',
|
||||
_ =>
|
||||
'${agentLabels.first} and ${agentLabels.length - 1} more agents are working',
|
||||
};
|
||||
|
||||
String _channelSummary(
|
||||
List<String> channelLabels,
|
||||
) => switch (channelLabels.length) {
|
||||
1 => 'In ${channelLabels.first}',
|
||||
2 => 'Across ${channelLabels.first} and ${channelLabels.last}',
|
||||
_ =>
|
||||
'Across ${channelLabels.take(2).join(', ')} and ${channelLabels.length - 2} more',
|
||||
};
|
||||
|
||||
Future<void> syncAgentLiveUpdate(AgentLiveUpdateContent? content) async {
|
||||
if (kIsWeb ||
|
||||
(defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (content == null) {
|
||||
await _agentLiveUpdatesChannel.invokeMethod<void>('dismiss');
|
||||
return;
|
||||
}
|
||||
await _agentLiveUpdatesChannel.invokeMethod<bool>(
|
||||
'show',
|
||||
content.toPlatformArguments(),
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
debugPrint('Unable to update agent live activity: ${error.message}');
|
||||
} on MissingPluginException {
|
||||
debugPrint('Agent live activity is unavailable in this build.');
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ class ObserverFrame {
|
||||
final String? channelId;
|
||||
final String? sessionId;
|
||||
final String? turnId;
|
||||
final String? startedAt;
|
||||
final dynamic payload;
|
||||
|
||||
const ObserverFrame({
|
||||
@@ -26,6 +27,7 @@ class ObserverFrame {
|
||||
this.channelId,
|
||||
this.sessionId,
|
||||
this.turnId,
|
||||
this.startedAt,
|
||||
this.payload,
|
||||
});
|
||||
|
||||
@@ -37,6 +39,7 @@ class ObserverFrame {
|
||||
channelId: json['channelId'] as String?,
|
||||
sessionId: json['sessionId'] as String?,
|
||||
turnId: json['turnId'] as String?,
|
||||
startedAt: json['startedAt'] as String?,
|
||||
payload: json['payload'],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -7,6 +8,7 @@ import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import '../../shared/auth/auth.dart';
|
||||
import '../../shared/clipboard_utils.dart';
|
||||
import '../../shared/notifications/agent_live_update_preferences.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/app_list.dart';
|
||||
@@ -18,6 +20,7 @@ import 'theme_picker_page.dart';
|
||||
|
||||
part 'settings_page/appearance_section.dart';
|
||||
part 'settings_page/connection_section.dart';
|
||||
part 'settings_page/notifications_section.dart';
|
||||
|
||||
class SettingsPage extends HookConsumerWidget {
|
||||
const SettingsPage({super.key, required this.profileHeader});
|
||||
@@ -42,6 +45,10 @@ class SettingsPage extends HookConsumerWidget {
|
||||
children: [
|
||||
profileHeader,
|
||||
const _AppearanceSection(),
|
||||
if (!kIsWeb &&
|
||||
(defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS))
|
||||
const _NotificationsSection(),
|
||||
const _ConnectionSection(),
|
||||
const _RemoveCommunitySection(),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
part of '../settings_page.dart';
|
||||
|
||||
class _NotificationsSection extends ConsumerWidget {
|
||||
const _NotificationsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final enabled = ref.watch(agentLiveUpdatesEnabledProvider);
|
||||
|
||||
void setEnabled(bool value) {
|
||||
ref.read(agentLiveUpdatesEnabledProvider.notifier).setEnabled(value);
|
||||
}
|
||||
|
||||
return AppListCard(
|
||||
label: 'Notifications',
|
||||
children: [
|
||||
AppListRow(
|
||||
icon: LucideIcons.bot,
|
||||
title: 'Agent activity',
|
||||
subtitle: 'Show live progress while agents are working',
|
||||
trailing: Switch(value: enabled, onChanged: setEnabled),
|
||||
onTap: () => setEnabled(!enabled),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../theme/theme_provider.dart';
|
||||
|
||||
const _agentLiveUpdatesEnabledKey = 'buzz_agent_live_updates_enabled';
|
||||
|
||||
/// Whether Buzz should show Live Updates for active agent work.
|
||||
///
|
||||
/// This is enabled by default. The operating system's notification and Live
|
||||
/// Activity settings remain the final authority over whether it surfaces.
|
||||
class AgentLiveUpdatesEnabledNotifier extends Notifier<bool> {
|
||||
@override
|
||||
bool build() =>
|
||||
ref.read(savedPrefsProvider).getBool(_agentLiveUpdatesEnabledKey) ?? true;
|
||||
|
||||
void setEnabled(bool enabled) {
|
||||
state = enabled;
|
||||
ref.read(savedPrefsProvider).setBool(_agentLiveUpdatesEnabledKey, enabled);
|
||||
}
|
||||
}
|
||||
|
||||
final agentLiveUpdatesEnabledProvider =
|
||||
NotifierProvider<AgentLiveUpdatesEnabledNotifier, bool>(
|
||||
AgentLiveUpdatesEnabledNotifier.new,
|
||||
);
|
||||
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/observer_models.dart';
|
||||
|
||||
void main() {
|
||||
test('tracks an active turn and refreshes it from liveness', () {
|
||||
final turns = reduceActiveAgentTurns({
|
||||
'AGENT-A': [
|
||||
_frame(
|
||||
seq: 1,
|
||||
second: 1,
|
||||
kind: 'turn_started',
|
||||
payload: {
|
||||
'triggeringEventIds': ['message-1'],
|
||||
},
|
||||
),
|
||||
_frame(seq: 2, second: 11, kind: 'turn_liveness'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(turns, hasLength(1));
|
||||
expect(turns.single.agentPubkey, 'agent-a');
|
||||
expect(turns.single.channelId, 'channel-1');
|
||||
expect(turns.single.turnId, 'turn-1');
|
||||
expect(turns.single.triggeringEventId, 'message-1');
|
||||
expect(turns.single.startedAt, DateTime.utc(2026, 7, 27, 12, 0, 1));
|
||||
expect(turns.single.lastActivityAt, DateTime.utc(2026, 7, 27, 12, 0, 11));
|
||||
});
|
||||
|
||||
test('removes a turn after a terminal frame', () {
|
||||
final turns = reduceActiveAgentTurns({
|
||||
'agent-a': [
|
||||
_frame(seq: 1, second: 1, kind: 'turn_started'),
|
||||
_frame(seq: 2, second: 2, kind: 'turn_completed'),
|
||||
],
|
||||
});
|
||||
|
||||
expect(turns, isEmpty);
|
||||
});
|
||||
|
||||
test('recovers a live turn when its start frame was missed', () {
|
||||
final turns = reduceActiveAgentTurns({
|
||||
'agent-a': [
|
||||
_frame(
|
||||
seq: 9,
|
||||
second: 30,
|
||||
kind: 'turn_liveness',
|
||||
startedAt: DateTime.utc(2026, 7, 27, 12).toIso8601String(),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
expect(turns, hasLength(1));
|
||||
expect(turns.single.startedAt, DateTime.utc(2026, 7, 27, 12));
|
||||
expect(turns.single.lastActivityAt, DateTime.utc(2026, 7, 27, 12, 0, 30));
|
||||
});
|
||||
|
||||
test('tracks concurrent turns independently', () {
|
||||
final turns = reduceActiveAgentTurns({
|
||||
'agent-a': [_frame(seq: 1, second: 1, kind: 'turn_started')],
|
||||
'agent-b': [
|
||||
_frame(
|
||||
seq: 1,
|
||||
second: 2,
|
||||
kind: 'turn_started',
|
||||
turnId: 'turn-2',
|
||||
channelId: 'channel-2',
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
expect(turns.map((turn) => turn.agentPubkey), ['agent-a', 'agent-b']);
|
||||
expect(turns.map((turn) => turn.channelId), ['channel-1', 'channel-2']);
|
||||
});
|
||||
}
|
||||
|
||||
ObserverFrame _frame({
|
||||
required int seq,
|
||||
required int second,
|
||||
required String kind,
|
||||
String turnId = 'turn-1',
|
||||
String channelId = 'channel-1',
|
||||
String? startedAt,
|
||||
dynamic payload = const <String, dynamic>{},
|
||||
}) {
|
||||
return ObserverFrame(
|
||||
seq: seq,
|
||||
timestamp: DateTime.utc(2026, 7, 27, 12, 0, second).toIso8601String(),
|
||||
kind: kind,
|
||||
channelId: channelId,
|
||||
turnId: turnId,
|
||||
startedAt: startedAt,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:buzz/features/channels/agent_activity/active_agent_turns.dart';
|
||||
import 'package:buzz/features/channels/agent_activity/agent_live_updates.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('builds a single-agent update with a deep-link target', () {
|
||||
final content = buildAgentLiveUpdateContent(
|
||||
[_turn(agent: 'agent-a', channel: 'channel-1', message: 'message-1')],
|
||||
{'channel-1': 'agents'},
|
||||
{'agent-a': 'Codex'},
|
||||
);
|
||||
|
||||
expect(content, isNotNull);
|
||||
expect(content!.title, 'Codex is working');
|
||||
expect(content.body, 'In #agents');
|
||||
expect(content.activeAgentCount, 1);
|
||||
expect(content.channelId, 'channel-1');
|
||||
expect(content.messageId, 'message-1');
|
||||
});
|
||||
|
||||
test('aggregates multiple agents and channels into one update', () {
|
||||
final content = buildAgentLiveUpdateContent(
|
||||
[
|
||||
_turn(agent: 'agent-a', channel: 'channel-1'),
|
||||
_turn(
|
||||
agent: 'agent-b',
|
||||
channel: 'channel-2',
|
||||
minute: 1,
|
||||
message: 'message-2',
|
||||
),
|
||||
_turn(agent: 'agent-b', channel: 'channel-2', minute: 2),
|
||||
],
|
||||
{'channel-1': 'agents', 'channel-2': 'design'},
|
||||
{'agent-a': 'Codex', 'agent-b': 'Maya'},
|
||||
);
|
||||
|
||||
expect(content, isNotNull);
|
||||
expect(content!.title, 'Codex and Maya are working');
|
||||
expect(content.body, 'Across #agents and #design');
|
||||
expect(content.activeAgentCount, 2);
|
||||
expect(content.startedAt, DateTime.utc(2026, 7, 27, 12));
|
||||
expect(content.channelId, 'channel-2');
|
||||
expect(content.messageId, 'message-2');
|
||||
});
|
||||
|
||||
test('returns no content without active turns', () {
|
||||
expect(buildAgentLiveUpdateContent(const [], const {}), isNull);
|
||||
});
|
||||
|
||||
test('summarizes additional agents and channels', () {
|
||||
final content = buildAgentLiveUpdateContent(
|
||||
[
|
||||
_turn(agent: 'agent-a', channel: 'channel-1'),
|
||||
_turn(agent: 'agent-b', channel: 'channel-2'),
|
||||
_turn(agent: 'agent-c', channel: 'channel-3'),
|
||||
],
|
||||
{
|
||||
'channel-1': 'agents',
|
||||
'channel-2': 'design',
|
||||
'channel-3': 'engineering',
|
||||
},
|
||||
{'agent-a': 'Codex', 'agent-b': 'Maya', 'agent-c': 'Theo'},
|
||||
);
|
||||
|
||||
expect(content, isNotNull);
|
||||
expect(content!.title, 'Codex and 2 more agents are working');
|
||||
expect(content.body, 'Across #agents, #design and 1 more');
|
||||
});
|
||||
|
||||
test('dismisses the Android update when no agent is active', () async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
final calls = <MethodCall>[];
|
||||
const channel = MethodChannel('buzz/agent_live_updates');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return true;
|
||||
});
|
||||
addTearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
});
|
||||
|
||||
await syncAgentLiveUpdate(null);
|
||||
|
||||
expect(calls, hasLength(1));
|
||||
expect(calls.single.method, 'dismiss');
|
||||
});
|
||||
|
||||
test('dismisses the iOS Live Activity when no agent is active', () async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final calls = <MethodCall>[];
|
||||
const channel = MethodChannel('buzz/agent_live_updates');
|
||||
final messenger =
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
calls.add(call);
|
||||
return true;
|
||||
});
|
||||
addTearDown(() {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
});
|
||||
|
||||
await syncAgentLiveUpdate(null);
|
||||
|
||||
expect(calls, hasLength(1));
|
||||
expect(calls.single.method, 'dismiss');
|
||||
});
|
||||
}
|
||||
|
||||
ActiveAgentTurn _turn({
|
||||
required String agent,
|
||||
required String channel,
|
||||
int minute = 0,
|
||||
String? message,
|
||||
}) {
|
||||
final startedAt = DateTime.utc(2026, 7, 27, 12, minute);
|
||||
return ActiveAgentTurn(
|
||||
agentPubkey: agent,
|
||||
channelId: channel,
|
||||
turnId: '$agent-$minute',
|
||||
startedAt: startedAt,
|
||||
lastActivityAt: startedAt.add(const Duration(seconds: 10)),
|
||||
triggeringEventId: message,
|
||||
);
|
||||
}
|
||||
110
mobile/test/features/settings/notifications_section_test.dart
Normal file
110
mobile/test/features/settings/notifications_section_test.dart
Normal file
@@ -0,0 +1,110 @@
|
||||
import 'package:buzz/features/settings/settings_page.dart';
|
||||
import 'package:buzz/shared/notifications/agent_live_update_preferences.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme_provider.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('shows the agent activity switch on Android', (tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.android;
|
||||
try {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
relayConfigProvider.overrideWith(_FakeRelayConfigNotifier.new),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: SettingsPage(profileHeader: SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Notifications'), findsOneWidget);
|
||||
expect(find.text('Agent activity'), findsOneWidget);
|
||||
expect(find.byType(Switch), findsOneWidget);
|
||||
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(SettingsPage)),
|
||||
);
|
||||
expect(container.read(agentLiveUpdatesEnabledProvider), isTrue);
|
||||
|
||||
await tester.tap(find.byType(Switch));
|
||||
await tester.pump();
|
||||
|
||||
expect(container.read(agentLiveUpdatesEnabledProvider), isFalse);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('shows the agent activity switch on iOS', (tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
try {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
relayConfigProvider.overrideWith(_FakeRelayConfigNotifier.new),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: SettingsPage(profileHeader: SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Notifications'), findsOneWidget);
|
||||
expect(find.text('Agent activity'), findsOneWidget);
|
||||
expect(find.byType(Switch), findsOneWidget);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('does not show the mobile setting on macOS', (tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.macOS;
|
||||
try {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
relayConfigProvider.overrideWith(_FakeRelayConfigNotifier.new),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: SettingsPage(profileHeader: SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('Notifications'), findsNothing);
|
||||
expect(find.text('Agent activity'), findsNothing);
|
||||
expect(find.byType(Switch), findsNothing);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeRelayConfigNotifier extends RelayConfigNotifier {
|
||||
@override
|
||||
RelayConfig build() => const RelayConfig(baseUrl: 'https://relay.example');
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:buzz/shared/notifications/agent_live_update_preferences.dart';
|
||||
import 'package:buzz/shared/theme/theme_provider.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test('agent Live Updates are enabled by default', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final container = ProviderContainer(
|
||||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
expect(container.read(agentLiveUpdatesEnabledProvider), isTrue);
|
||||
});
|
||||
|
||||
test('disabled preference persists across provider containers', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final firstContainer = ProviderContainer(
|
||||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||
);
|
||||
|
||||
firstContainer
|
||||
.read(agentLiveUpdatesEnabledProvider.notifier)
|
||||
.setEnabled(false);
|
||||
expect(firstContainer.read(agentLiveUpdatesEnabledProvider), isFalse);
|
||||
firstContainer.dispose();
|
||||
|
||||
final secondContainer = ProviderContainer(
|
||||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||
);
|
||||
addTearDown(secondContainer.dispose);
|
||||
|
||||
expect(secondContainer.read(agentLiveUpdatesEnabledProvider), isFalse);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user