Close remaining Live Update expiry gaps

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-07-28 20:05:01 +01:00
parent 0b70b8b086
commit 5e0ea504db
6 changed files with 163 additions and 11 deletions

View File

@@ -53,6 +53,9 @@
android:name="flutter_deeplinking_enabled"
android:value="false" />
</activity>
<receiver
android:name=".AgentLiveUpdateExpiryReceiver"
android:exported="false" />
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data

View File

@@ -2,21 +2,37 @@ package xyz.block.buzz.mobile
import android.Manifest
import android.app.Activity
import android.app.AlarmManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
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 EXPIRY_INTENT_REQUEST_CODE = 24_203
private const val EXPIRY_INTENT_ACTION = "xyz.block.buzz.mobile.AGENT_LIVE_UPDATE_EXPIRED"
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 class AgentLiveUpdateManager(
private val activity: Activity,
) {
private val notificationManager =
activity.getSystemService(NotificationManager::class.java)
private val alarmManager =
activity.getSystemService(AlarmManager::class.java)
private val preferences =
activity.getSharedPreferences(PREFERENCES_NAME, Activity.MODE_PRIVATE)
@@ -37,6 +53,7 @@ internal class AgentLiveUpdateManager(
fun dismiss() {
pendingPayload = null
cancelLegacyExpiry()
notificationManager.cancel(NOTIFICATION_TAG, NOTIFICATION_ID)
}
@@ -125,6 +142,7 @@ internal class AgentLiveUpdateManager(
.build()
notificationManager.notify(NOTIFICATION_TAG, NOTIFICATION_ID, notification)
scheduleLegacyExpiry(payload.expiresAtMillis)
}
private fun newNotificationBuilder(): Notification.Builder =
@@ -151,6 +169,34 @@ internal class AgentLiveUpdateManager(
notificationManager.createNotificationChannel(channel)
}
private fun scheduleLegacyExpiry(expiresAtMillis: Long) {
if (Build.VERSION.SDK_INT >= 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)
}
}

View File

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

View File

@@ -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<ObserverRelayState> {
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<ObserverRelayState> {
kinds: [EventKind.agentObserverFrame],
tags: filterTags,
limit: _observerReplayLimit,
since: replaySince,
until: replayBoundary,
),
);
@@ -271,9 +274,12 @@ class ObserverRelayNotifier extends Notifier<ObserverRelayState> {
);
final plaintext = nip44Decrypt(conversationKey, event.content);
final json = jsonDecode(plaintext) as Map<String, dynamic>;
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<ObserverRelayState> {
}
}
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(

View File

@@ -13,6 +13,7 @@ class AgentLiveUpdatesEnabledNotifier extends Notifier<bool> {
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);

View File

@@ -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<void>.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 {