diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml
index 0b28c46f51..470c9ff90e 100644
--- a/mobile/android/app/src/main/AndroidManifest.xml
+++ b/mobile/android/app/src/main/AndroidManifest.xml
@@ -53,6 +53,9 @@
android:name="flutter_deeplinking_enabled"
android:value="false" />
+
= Build.VERSION_CODES.O) {
+ cancelLegacyExpiry()
+ return
+ }
+ alarmManager.setExactAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ legacyExpiryTriggerAt(expiresAtMillis, System.currentTimeMillis()),
+ expiryIntent(),
+ )
+ }
+
+ private fun cancelLegacyExpiry() {
+ val intent = expiryIntent()
+ alarmManager.cancel(intent)
+ intent.cancel()
+ }
+
+ private fun expiryIntent(): PendingIntent =
+ PendingIntent.getBroadcast(
+ activity,
+ EXPIRY_INTENT_REQUEST_CODE,
+ Intent(activity, AgentLiveUpdateExpiryReceiver::class.java).apply {
+ action = EXPIRY_INTENT_ACTION
+ },
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
private fun contentIntent(payload: AgentLiveUpdatePayload): PendingIntent {
val messageId = payload.messageId
val intent = if (messageId != null) {
@@ -176,15 +222,21 @@ internal class AgentLiveUpdateManager(
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"
+internal fun legacyExpiryTriggerAt(
+ expiresAtMillis: Long,
+ nowMillis: Long,
+): Long = expiresAtMillis.coerceAtLeast(nowMillis + 1L)
+
+internal class AgentLiveUpdateExpiryReceiver : BroadcastReceiver() {
+ override fun onReceive(
+ context: Context,
+ intent: Intent,
+ ) {
+ if (intent.action != EXPIRY_INTENT_ACTION) return
+ context
+ .getSystemService(NotificationManager::class.java)
+ .cancel(NOTIFICATION_TAG, NOTIFICATION_ID)
}
}
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
index 9da9fbcba3..bf360742e2 100644
--- 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
@@ -5,6 +5,16 @@ import kotlin.test.assertEquals
import kotlin.test.assertNull
class AgentLiveUpdatePayloadTest {
+ @Test
+ fun legacyExpiryTriggerUsesPayloadDeadlineWhenStillFuture() {
+ assertEquals(2_000L, legacyExpiryTriggerAt(2_000L, 1_000L))
+ }
+
+ @Test
+ fun legacyExpiryTriggerSchedulesImmediateCancellationWhenAlreadyExpired() {
+ assertEquals(1_001L, legacyExpiryTriggerAt(500L, 1_000L))
+ }
+
@Test
fun `parses Flutter method channel values`() {
val payload = AgentLiveUpdatePayload.from(
diff --git a/mobile/lib/features/channels/agent_activity/observer_subscription.dart b/mobile/lib/features/channels/agent_activity/observer_subscription.dart
index 62bb66d252..f9ac961b99 100644
--- a/mobile/lib/features/channels/agent_activity/observer_subscription.dart
+++ b/mobile/lib/features/channels/agent_activity/observer_subscription.dart
@@ -12,6 +12,7 @@ import 'transcript_builder.dart';
/// Maximum observer events to keep per agent.
const _maxObserverEvents = 800;
const _observerReplayLimit = 200;
+const _observerReplayWindow = Duration(hours: 24, minutes: 1);
/// Key for channel-scoped transcript reads.
typedef ObserverKey = ({String channelId, String agentPubkey});
@@ -134,6 +135,7 @@ class ObserverRelayNotifier extends Notifier {
final session = ref.read(relaySessionProvider.notifier);
final replayBoundary =
DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000;
+ final replaySince = replayBoundary - _observerReplayWindow.inSeconds;
final filterTags = {
'#p': [ownerPubkey],
};
@@ -144,6 +146,7 @@ class ObserverRelayNotifier extends Notifier {
kinds: [EventKind.agentObserverFrame],
tags: filterTags,
limit: _observerReplayLimit,
+ since: replaySince,
until: replayBoundary,
),
);
@@ -271,9 +274,12 @@ class ObserverRelayNotifier extends Notifier {
);
final plaintext = nip44Decrypt(conversationKey, event.content);
final json = jsonDecode(plaintext) as Map;
+ final receivedAt = isHistorical
+ ? _historicalFrameTime(event)
+ : DateTime.now().toUtc();
return ObserverFrame.fromJson(
json,
- receivedAt: DateTime.now().toUtc(),
+ receivedAt: receivedAt,
isHistorical: isHistorical,
);
} catch (error) {
@@ -283,6 +289,18 @@ class ObserverRelayNotifier extends Notifier {
}
}
+ DateTime _historicalFrameTime(NostrEvent event) {
+ final now = DateTime.now().toUtc();
+ final eventAt = DateTime.fromMillisecondsSinceEpoch(
+ event.createdAt * Duration.millisecondsPerSecond,
+ isUtc: true,
+ );
+ // Historical frames were not received now. Preserve their signed event age
+ // so replay cannot make an old, unterminated turn look newly active. Clamp
+ // future-dated events to now so host clock skew cannot extend freshness.
+ return eventAt.isAfter(now) ? now : eventAt;
+ }
+
void _emit({required ObserverConnectionState connection}) {
if (_disposed) return;
state = ObserverRelayState(
diff --git a/mobile/lib/shared/notifications/agent_live_update_preferences.dart b/mobile/lib/shared/notifications/agent_live_update_preferences.dart
index 0f5a3bb547..e440f31cc3 100644
--- a/mobile/lib/shared/notifications/agent_live_update_preferences.dart
+++ b/mobile/lib/shared/notifications/agent_live_update_preferences.dart
@@ -13,6 +13,7 @@ class AgentLiveUpdatesEnabledNotifier extends Notifier {
bool build() =>
ref.read(savedPrefsProvider).getBool(_agentLiveUpdatesEnabledKey) ?? true;
+ /// Persists whether agent Live Updates should be shown on this device.
void setEnabled(bool enabled) {
state = enabled;
ref.read(savedPrefsProvider).setBool(_agentLiveUpdatesEnabledKey, enabled);
diff --git a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart
index 9b27152b65..0d1d050ddb 100644
--- a/mobile/test/features/channels/agent_activity/observer_subscription_test.dart
+++ b/mobile/test/features/channels/agent_activity/observer_subscription_test.dart
@@ -148,6 +148,10 @@ void main() {
expect(historyFilter.limit, 200);
expect(historyFilter.tags['#p'], contains(myPubkey));
expect(historyFilter.until, filter.since);
+ expect(
+ historyFilter.until! - historyFilter.since!,
+ const Duration(hours: 24, minutes: 1).inSeconds,
+ );
},
);
@@ -312,6 +316,59 @@ void main() {
expect((state.transcript.single as LifecycleItem).title, 'Turn started');
});
+ test('preserves replay age so stale turns do not restart', () async {
+ final ownerKeychain = nostr.Keys.generate();
+ final agentKeychain = nostr.Keys.generate();
+ final nowSeconds =
+ DateTime.now().toUtc().millisecondsSinceEpoch ~/
+ Duration.millisecondsPerSecond;
+ final eventCreatedAt = nowSeconds - 120;
+ final relaySession = _RecordingRelaySession()
+ ..historyEvents = [
+ _observerEvent(
+ ownerKeychain: ownerKeychain,
+ agentKeychain: agentKeychain,
+ channelId: 'test-channel',
+ seq: 1,
+ createdAt: eventCreatedAt,
+ ),
+ ];
+ final container = ProviderContainer(
+ overrides: [
+ relaySessionProvider.overrideWith(() => relaySession),
+ relayConfigProvider.overrideWith(
+ () => _FakeRelayConfigNotifier(nsec: ownerKeychain.nsec),
+ ),
+ ],
+ );
+ addTearDown(container.dispose);
+
+ const channelId = 'test-channel';
+ final key = (channelId: channelId, agentPubkey: agentKeychain.public);
+ container.read(observerSubscriptionProvider(key));
+ await Future.delayed(Duration.zero);
+
+ final relayState = container.read(observerRelayProvider);
+ final replayFrame = relayState.framesByAgent[agentKeychain.public]!.single;
+ expect(
+ replayFrame.receivedAt,
+ DateTime.fromMillisecondsSinceEpoch(
+ eventCreatedAt * Duration.millisecondsPerSecond,
+ isUtc: true,
+ ),
+ );
+ expect(
+ reduceActiveAgentTurns(
+ relayState.framesByAgent,
+ now: DateTime.fromMillisecondsSinceEpoch(
+ nowSeconds * Duration.millisecondsPerSecond,
+ isUtc: true,
+ ),
+ ),
+ isEmpty,
+ );
+ });
+
test(
'decrypts observer frames and exposes channel-scoped transcript',
() async {
@@ -459,6 +516,7 @@ NostrEvent _observerEvent({
required nostr.Keys agentKeychain,
required String channelId,
required int seq,
+ int? createdAt,
}) {
final conversationKey = getConversationKey(
agentKeychain.secret,
@@ -485,7 +543,17 @@ NostrEvent _observerEvent({
secretKey: agentKeychain.secret,
verify: false,
);
- return NostrEvent.fromJson(event.toMap());
+ final signedEvent = NostrEvent.fromJson(event.toMap());
+ if (createdAt == null) return signedEvent;
+ return NostrEvent(
+ id: signedEvent.id,
+ pubkey: signedEvent.pubkey,
+ createdAt: createdAt,
+ kind: signedEvent.kind,
+ tags: signedEvent.tags,
+ content: signedEvent.content,
+ sig: signedEvent.sig,
+ );
}
class _FakeRelayConfigNotifier extends RelayConfigNotifier {