From 5e0ea504db1cdf81624981a1fbaad32b6c1cc6cb Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Tue, 28 Jul 2026 20:05:01 +0100 Subject: [PATCH] Close remaining Live Update expiry gaps Signed-off-by: kenny lopez --- .../android/app/src/main/AndroidManifest.xml | 3 + .../buzz/mobile/AgentLiveUpdateManager.kt | 70 ++++++++++++++++--- .../buzz/mobile/AgentLiveUpdatePayloadTest.kt | 10 +++ .../agent_activity/observer_subscription.dart | 20 +++++- .../agent_live_update_preferences.dart | 1 + .../observer_subscription_test.dart | 70 ++++++++++++++++++- 6 files changed, 163 insertions(+), 11 deletions(-) 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 {