Add mobile agent Live Updates

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-07-28 15:53:40 +01:00
parent 4a977c588a
commit bd9ecda93f
30 changed files with 1939 additions and 9 deletions

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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}"

View File

@@ -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"
}
}

View File

@@ -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() },
)
}
}
}

View File

@@ -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"

View File

@@ -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>

View 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>

View File

@@ -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",
),
),
)
}
}

View 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()
}
}
}

View 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>

View 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

View File

@@ -0,0 +1,4 @@
#include "Generated.xcconfig"
BUNDLE_IDENTIFIER = com.buzz.buzzMobile
#include? "AppOverrides.xcconfig"
AGENT_ACTIVITY_WIDGET_BUNDLE_IDENTIFIER = $(BUNDLE_IDENTIFIER).AgentActivityWidget

View File

@@ -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 = (

View 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)
}
}
}

View File

@@ -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(

View File

@@ -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>

View File

@@ -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(

View 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
}

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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.');
}
}

View File

@@ -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'],
);
}

View File

@@ -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(),
],

View File

@@ -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),
),
],
);
}
}

View File

@@ -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,
);

View File

@@ -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,
);
}

View File

@@ -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,
);
}

View 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');
}

View File

@@ -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);
});
}