diff --git a/mobile/android/app/src/debug/AndroidManifest.xml b/mobile/android/app/src/debug/AndroidManifest.xml
index 399f6981d5..85d44452f0 100644
--- a/mobile/android/app/src/debug/AndroidManifest.xml
+++ b/mobile/android/app/src/debug/AndroidManifest.xml
@@ -4,4 +4,12 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
+
+
+
diff --git a/mobile/android/app/src/debug/kotlin/xyz/block/buzz/mobile/AgentLiveUpdateDemoActivity.kt b/mobile/android/app/src/debug/kotlin/xyz/block/buzz/mobile/AgentLiveUpdateDemoActivity.kt
new file mode 100644
index 0000000000..5163e34733
--- /dev/null
+++ b/mobile/android/app/src/debug/kotlin/xyz/block/buzz/mobile/AgentLiveUpdateDemoActivity.kt
@@ -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
+ }
+}
diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 17a16742af..e665815e76 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -1,6 +1,8 @@
+
+
= 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"
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AgentLiveUpdatePayload.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AgentLiveUpdatePayload.kt
new file mode 100644
index 0000000000..2c873812d1
--- /dev/null
+++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/AgentLiveUpdatePayload.kt
@@ -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() },
+ )
+ }
+ }
+}
diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt
index 47582fc08c..11700aba38 100644
--- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt
+++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/MainActivity.kt
@@ -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,
+ 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"
diff --git a/mobile/android/app/src/main/res/drawable/ic_agent_live_update.xml b/mobile/android/app/src/main/res/drawable/ic_agent_live_update.xml
new file mode 100644
index 0000000000..c779e9509b
--- /dev/null
+++ b/mobile/android/app/src/main/res/drawable/ic_agent_live_update.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/mobile/android/app/src/main/res/values/strings.xml b/mobile/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..9277221700
--- /dev/null
+++ b/mobile/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,9 @@
+
+ Agent activity
+ Live progress for running Buzz agents
+ Open Buzz to view progress
+
+ - Agent is working
+ - %d agents are working
+
+
diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgentLiveUpdatePayloadTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgentLiveUpdatePayloadTest.kt
new file mode 100644
index 0000000000..881f52a775
--- /dev/null
+++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/AgentLiveUpdatePayloadTest.kt
@@ -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",
+ ),
+ ),
+ )
+ }
+}
diff --git a/mobile/ios/AgentActivityWidget/AgentActivityWidget.swift b/mobile/ios/AgentActivityWidget/AgentActivityWidget.swift
new file mode 100644
index 0000000000..e4a2ea3bd4
--- /dev/null
+++ b/mobile/ios/AgentActivityWidget/AgentActivityWidget.swift
@@ -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()
+ }
+ }
+}
diff --git a/mobile/ios/AgentActivityWidget/Info.plist b/mobile/ios/AgentActivityWidget/Info.plist
new file mode 100644
index 0000000000..17e569ad37
--- /dev/null
+++ b/mobile/ios/AgentActivityWidget/Info.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Agent activity
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+ NSSupportsLiveActivities
+
+
+
diff --git a/mobile/ios/Flutter/AgentActivityWidgetDebug.xcconfig b/mobile/ios/Flutter/AgentActivityWidgetDebug.xcconfig
new file mode 100644
index 0000000000..2b59ee7930
--- /dev/null
+++ b/mobile/ios/Flutter/AgentActivityWidgetDebug.xcconfig
@@ -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
diff --git a/mobile/ios/Flutter/AgentActivityWidgetRelease.xcconfig b/mobile/ios/Flutter/AgentActivityWidgetRelease.xcconfig
new file mode 100644
index 0000000000..fa969e1882
--- /dev/null
+++ b/mobile/ios/Flutter/AgentActivityWidgetRelease.xcconfig
@@ -0,0 +1,4 @@
+#include "Generated.xcconfig"
+BUNDLE_IDENTIFIER = com.buzz.buzzMobile
+#include? "AppOverrides.xcconfig"
+AGENT_ACTIVITY_WIDGET_BUNDLE_IDENTIFIER = $(BUNDLE_IDENTIFIER).AgentActivityWidget
diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index 46c61ce2fc..b7d40883c1 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -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 = ""; };
+ 6AC79440C8E44CE8BC2BDA08 /* AgentActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityAttributes.swift; sourceTree = ""; };
+ 4382D7DC2F2B48EDAA65EA9A /* AgentActivityWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgentActivityWidget.swift; sourceTree = ""; };
+ 200E80220AC94B00A78481AD /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 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 = ""; };
+ C90DB9EDB64C4428B725E26C /* AgentActivityWidgetRelease.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = AgentActivityWidgetRelease.xcconfig; path = Flutter/AgentActivityWidgetRelease.xcconfig; sourceTree = ""; };
+ 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 = ""; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
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 = ""; };
@@ -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 = "";
+ };
+ 5D5D501C06C94AE0A4D79DBE /* AgentActivityWidget */ = {
+ isa = PBXGroup;
+ children = (
+ 4382D7DC2F2B48EDAA65EA9A /* AgentActivityWidget.swift */,
+ D4A1B2C3D4E5F60718293A4B /* buzz-icon.png */,
+ 200E80220AC94B00A78481AD /* Info.plist */,
+ );
+ path = AgentActivityWidget;
+ sourceTree = "";
+ };
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 = (
diff --git a/mobile/ios/Runner/AgentLiveActivityManager.swift b/mobile/ios/Runner/AgentLiveActivityManager.swift
new file mode 100644
index 0000000000..81afe591fe
--- /dev/null
+++ b/mobile/ios/Runner/AgentLiveActivityManager.swift
@@ -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.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.activities {
+ await end(activity)
+ }
+ }
+
+ private static func update(
+ _ activity: Activity,
+ 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
+ ) async {
+ if #available(iOS 16.2, *) {
+ await activity.end(nil, dismissalPolicy: .immediate)
+ } else {
+ await activity.end(using: nil, dismissalPolicy: .immediate)
+ }
+ }
+}
diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift
index 5edb4b6aaf..5211e6064f 100644
--- a/mobile/ios/Runner/AppDelegate.swift
+++ b/mobile/ios/Runner/AppDelegate.swift
@@ -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(
diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist
index 70ba34a11f..55892579df 100644
--- a/mobile/ios/Runner/Info.plist
+++ b/mobile/ios/Runner/Info.plist
@@ -47,6 +47,8 @@
Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing.
NSPhotoLibraryUsageDescription
Buzz needs photo library access so you can attach images to messages.
+ NSSupportsLiveActivities
+
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift
index a174c78684..9877172214 100644
--- a/mobile/ios/RunnerTests/RunnerTests.swift
+++ b/mobile/ios/RunnerTests/RunnerTests.swift
@@ -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(
diff --git a/mobile/ios/Shared/AgentActivityAttributes.swift b/mobile/ios/Shared/AgentActivityAttributes.swift
new file mode 100644
index 0000000000..f97aa11a0a
--- /dev/null
+++ b/mobile/ios/Shared/AgentActivityAttributes.swift
@@ -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
+}
diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart
index 3d6a562a92..fe106fc01a 100644
--- a/mobile/lib/app.dart
+++ b/mobile/lib/app.dart
@@ -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 [];
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);
diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart
new file mode 100644
index 0000000000..a473b4f563
--- /dev/null
+++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart
@@ -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 reduceActiveAgentTurns(
+ Map> framesByAgent,
+) {
+ final activeTurns = [];
+
+ for (final entry in framesByAgent.entries) {
+ final agentPubkey = entry.key.toLowerCase();
+ final frames = [...entry.value]..sort(_compareFrames);
+ final turnsById = {};
+ final terminalAtById = {};
+
+ 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>((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);
+}
diff --git a/mobile/lib/features/channels/agent_activity/agent_live_updates.dart b/mobile/lib/features/channels/agent_activity/agent_live_updates.dart
new file mode 100644
index 0000000000..69d0385efd
--- /dev/null
+++ b/mobile/lib/features/channels/agent_activity/agent_live_updates.dart
@@ -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 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 turns,
+ Map channelNames, [
+ Map 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((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 agentNames) {
+ final name = agentNames[pubkey]?.trim();
+ return name?.isNotEmpty == true ? name! : shortPubkey(pubkey);
+}
+
+String _agentTitle(List 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 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 syncAgentLiveUpdate(AgentLiveUpdateContent? content) async {
+ if (kIsWeb ||
+ (defaultTargetPlatform != TargetPlatform.android &&
+ defaultTargetPlatform != TargetPlatform.iOS)) {
+ return;
+ }
+
+ try {
+ if (content == null) {
+ await _agentLiveUpdatesChannel.invokeMethod('dismiss');
+ return;
+ }
+ await _agentLiveUpdatesChannel.invokeMethod(
+ '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.');
+ }
+}
diff --git a/mobile/lib/features/channels/agent_activity/observer_models.dart b/mobile/lib/features/channels/agent_activity/observer_models.dart
index b1cc8fe22c..ee2b4bb197 100644
--- a/mobile/lib/features/channels/agent_activity/observer_models.dart
+++ b/mobile/lib/features/channels/agent_activity/observer_models.dart
@@ -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'],
);
}
diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart
index f089f63915..d9dbb2943b 100644
--- a/mobile/lib/features/settings/settings_page.dart
+++ b/mobile/lib/features/settings/settings_page.dart
@@ -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(),
],
diff --git a/mobile/lib/features/settings/settings_page/notifications_section.dart b/mobile/lib/features/settings/settings_page/notifications_section.dart
new file mode 100644
index 0000000000..6a2825daf1
--- /dev/null
+++ b/mobile/lib/features/settings/settings_page/notifications_section.dart
@@ -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),
+ ),
+ ],
+ );
+ }
+}
diff --git a/mobile/lib/shared/notifications/agent_live_update_preferences.dart b/mobile/lib/shared/notifications/agent_live_update_preferences.dart
new file mode 100644
index 0000000000..0f5a3bb547
--- /dev/null
+++ b/mobile/lib/shared/notifications/agent_live_update_preferences.dart
@@ -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 {
+ @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.new,
+ );
diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart
new file mode 100644
index 0000000000..85426aece9
--- /dev/null
+++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart
@@ -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 {},
+}) {
+ return ObserverFrame(
+ seq: seq,
+ timestamp: DateTime.utc(2026, 7, 27, 12, 0, second).toIso8601String(),
+ kind: kind,
+ channelId: channelId,
+ turnId: turnId,
+ startedAt: startedAt,
+ payload: payload,
+ );
+}
diff --git a/mobile/test/features/channels/agent_activity/agent_live_updates_test.dart b/mobile/test/features/channels/agent_activity/agent_live_updates_test.dart
new file mode 100644
index 0000000000..a815dfe5a8
--- /dev/null
+++ b/mobile/test/features/channels/agent_activity/agent_live_updates_test.dart
@@ -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 = [];
+ 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 = [];
+ 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,
+ );
+}
diff --git a/mobile/test/features/settings/notifications_section_test.dart b/mobile/test/features/settings/notifications_section_test.dart
new file mode 100644
index 0000000000..9bbf028e34
--- /dev/null
+++ b/mobile/test/features/settings/notifications_section_test.dart
@@ -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');
+}
diff --git a/mobile/test/shared/notifications/agent_live_update_preferences_test.dart b/mobile/test/shared/notifications/agent_live_update_preferences_test.dart
new file mode 100644
index 0000000000..7eade3febc
--- /dev/null
+++ b/mobile/test/shared/notifications/agent_live_update_preferences_test.dart
@@ -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);
+ });
+}