mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
Close remaining Live Update expiry gaps
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user