Skip to content

Commit 729c3d4

Browse files
authored
SPEC-07 Slice 2: background wake via content-free APNs push (#20)
* docs(spec): SPEC-07 background wake — implementation-ready (reviewed + revised) Architect spec for Slice 2: content-free APNs wake for force-quit/suspended app. Server sends to APNs directly (user's own .p8, no relay); wake gated on a real dispatch (Noop sender rejects immediately — no 5-min hang); pendingCount from ReverseRpc; stale-token cleanup on 410; replay-on-auth. Passed spec review (BLOCKER + 3 MAJOR + 2 MINOR resolved). * feat(notifications): SPEC-07 Slice 2 — background wake via content-free APNs * fix(notifications): address SPEC-07 review — device push wiring, graceful APNs config failure, robustness, coverage * polish(notifications): test reconnect re-registration + detach push callback on dispose * style: dart format SPEC-07 files * test(fixtures): sync app events.json with server (agent.thinking.delta) The shared contract fixtures must be byte-identical (CI fixture-sync gate). c1d384e added a seq-13 agent.thinking.delta event to the server fixture but not the app copy — a pre-existing drift on main this PR's CI surfaced. App side fully supports the event; contract test still green.
1 parent 7c9ad4e commit 729c3d4

29 files changed

Lines changed: 2524 additions & 100 deletions

app/ios/Runner/AppDelegate.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import UserNotifications
66
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
77
// Retains the device-info channel for the lifetime of the app.
88
private var deviceInfoChannel: FlutterMethodChannel?
9+
// SPEC-07: retains the push channel used to forward the APNs token to Dart.
10+
private var pushChannel: FlutterMethodChannel?
911

1012
override func application(
1113
_ application: UIApplication,
@@ -16,9 +18,28 @@ import UserNotifications
1618
if #available(iOS 10.0, *) {
1719
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate
1820
}
21+
// SPEC-07: register for content-free wake pushes. The token is forwarded to
22+
// Dart (`pino/push` channel → PushRegistrar) which sends `push.register`.
23+
application.registerForRemoteNotifications()
1924
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
2025
}
2126

27+
// SPEC-07: APNs delivered a device token → forward its hex form to Dart.
28+
override func application(
29+
_ application: UIApplication,
30+
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
31+
) {
32+
let token = deviceToken.map { String(format: "%02x", $0) }.joined()
33+
pushChannel?.invokeMethod("didRegister", arguments: token)
34+
}
35+
36+
override func application(
37+
_ application: UIApplication,
38+
didFailToRegisterForRemoteNotificationsWithError error: Error
39+
) {
40+
pushChannel?.invokeMethod("didFail", arguments: error.localizedDescription)
41+
}
42+
2243
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
2344
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
2445
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "QrScannerPlugin") {
@@ -40,5 +61,14 @@ import UserNotifications
4061
}
4162
deviceInfoChannel = channel
4263
}
64+
// SPEC-07: `pino/push` → forwards the native APNs token to the Dart
65+
// PushRegistrar. The Dart default is NoopPushRegistrar; a channel-backed
66+
// registrar (on-device seam) consumes `didRegister` to send push.register.
67+
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "PinoPush") {
68+
pushChannel = FlutterMethodChannel(
69+
name: "pino/push",
70+
binaryMessenger: registrar.messenger()
71+
)
72+
}
4373
}
4474
}

app/ios/Runner/Info.plist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,11 @@
7676
</array>
7777
<key>UIStatusBarHidden</key>
7878
<false/>
79+
<!-- SPEC-07: content-available wake pushes need the remote-notification
80+
background mode so a suspended/killed app can be woken by APNs. -->
81+
<key>UIBackgroundModes</key>
82+
<array>
83+
<string>remote-notification</string>
84+
</array>
7985
</dict>
8086
</plist>

app/ios/Runner/Runner.entitlements

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<!-- SPEC-07: APNs environment. Xcode's Push Notifications capability
6+
manages this; `development` for the sandbox, `production` for a
7+
release build. Enable Push Notifications + Background Modes →
8+
Remote notifications in the Runner target. -->
9+
<key>aps-environment</key>
10+
<string>development</string>
11+
</dict>
12+
</plist>

app/lib/main.dart

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,18 @@ import 'package:flutter/material.dart';
55
import 'package:flutter_riverpod/flutter_riverpod.dart';
66

77
import 'package:go_router/go_router.dart';
8+
import 'package:shared_preferences/shared_preferences.dart';
89

910
import 'app/router.dart';
1011
import 'app/theme.dart';
1112
import 'app/test_bootstrap.dart';
1213
import 'notifications/notification_observer.dart';
1314
import 'notifications/notification_request.dart';
15+
import 'notifications/pending_action_drain.dart';
16+
import 'notifications/push_registration.dart';
1417
import 'store/connection.dart';
1518
import 'store/store.dart';
19+
import 'transport/transport.dart';
1620
import 'ui/widgets/pino_mark.dart';
1721
import 'ui/widgets/srv_request_handler.dart';
1822
import 'desktop/desktop_app.dart';
@@ -32,7 +36,15 @@ Future<void> main() async {
3236
// The store listens to a broadcast stream that drops events without
3337
// listeners. Eagerly create the controller so it's subscribed before the
3438
// WS connects and starts pushing projects/sessions snapshots.
35-
final container = ProviderContainer();
39+
//
40+
// SPEC-07: inject a channel-backed push registrar so the APNs token the iOS
41+
// `AppDelegate` forwards over `pino/push` reaches the controller, which then
42+
// sends `push.register`. Tests keep the default NoopPushRegistrar.
43+
final container = ProviderContainer(
44+
overrides: [
45+
pushRegistrarProvider.overrideWithValue(ChannelPushRegistrar()),
46+
],
47+
);
3648
container.read(storeControllerProvider);
3749

3850
// Notifications: route taps into the session and activate the status→notif
@@ -61,6 +73,18 @@ Future<void> main() async {
6173
};
6274
container.read(notificationControllerProvider);
6375

76+
// SPEC-07: on every `wsState → connected` transition, drain the force-quit
77+
// pending-action queue (taps captured by the background isolate while the
78+
// app was dead) through the same responseForAction + respondTo path. Mirrors
79+
// store.dart's re-subscribe-on-reconnect listener. Idempotent via respondTo.
80+
container.listen<PinoConnState>(connectionControllerProvider, (prev, next) {
81+
final wasConnected = prev?.wsState == WsState.connected;
82+
final nowConnected = next.wsState == WsState.connected;
83+
if (!wasConnected && nowConnected) {
84+
unawaited(_drainPendingActions(container));
85+
}
86+
}, fireImmediately: false);
87+
6488
runApp(
6589
UncontrolledProviderScope(container: container, child: const PinoApp()),
6690
);
@@ -73,6 +97,20 @@ Future<void> main() async {
7397
}
7498
}
7599

100+
/// SPEC-07: drain the persisted force-quit pending-action queue through
101+
/// `respondTo` (idempotent). Best-effort — a failure never blocks startup.
102+
Future<void> _drainPendingActions(ProviderContainer container) async {
103+
try {
104+
final prefs = await SharedPreferences.getInstance();
105+
final respond = container
106+
.read(connectionControllerProvider.notifier)
107+
.respondTo;
108+
await PendingActionDrainer(prefs, respond).drain();
109+
} catch (_) {
110+
// Best-effort: a failed drain must not crash the app.
111+
}
112+
}
113+
76114
class PinoApp extends ConsumerStatefulWidget {
77115
const PinoApp({super.key});
78116

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/// SPEC-07 Slice 2 — draining the force-quit pending-action queue.
2+
///
3+
/// When the user taps an actionable notification while the app process is dead,
4+
/// Slice-1's `notificationBackgroundHandler` isolate persists
5+
/// `{payload, actionId, input}` to SharedPreferences (`kPendingActionsKey`). On
6+
/// the next launch/reconnect we drain that queue through the SAME pure mapping
7+
/// the live-isolate path uses (`parseNotificationPayload` + `responseForAction`)
8+
/// and replay each response via the injected `respond` (the `respondTo`
9+
/// tear-off), then clear the queue. Idempotency is guaranteed downstream by
10+
/// `respondTo`'s `_respondedRequests` guard (SPEC-08 step 4).
11+
library;
12+
13+
import 'dart:convert';
14+
15+
import 'package:flutter/foundation.dart';
16+
import 'package:shared_preferences/shared_preferences.dart';
17+
18+
import 'notification_request.dart';
19+
import 'notification_service.dart';
20+
21+
/// A planned replay: a request id and the `srv.response` body to send.
22+
@immutable
23+
class PendingReplay {
24+
const PendingReplay(this.requestId, this.body);
25+
26+
final String requestId;
27+
final Map<String, dynamic> body;
28+
}
29+
30+
/// Pure: map a raw pending-action queue (FIFO) to the replays to perform.
31+
///
32+
/// Each [rawQueue] entry is the `{payload, actionId, input}` JSON written by
33+
/// `notificationBackgroundHandler`. Entries with garbage JSON, an unknown
34+
/// action, a missing request id, or an action/kind mismatch are skipped;
35+
/// surviving entries keep their original order.
36+
List<PendingReplay> planDrain(List<String> rawQueue) {
37+
final plan = <PendingReplay>[];
38+
for (final raw in rawQueue) {
39+
final Map<String, dynamic> entry;
40+
try {
41+
final decoded = jsonDecode(raw);
42+
if (decoded is! Map<String, dynamic>) continue;
43+
entry = decoded;
44+
} on FormatException {
45+
continue;
46+
}
47+
final payload = entry['payload'] as String?;
48+
final actionId = entry['actionId'] as String?;
49+
final input = entry['input'] as String?;
50+
if (actionId == null || actionId.isEmpty) continue;
51+
52+
final parsed = parseNotificationPayload(payload);
53+
final rid = parsed.requestId;
54+
final kind = parsed.kind;
55+
if (rid == null || rid.isEmpty || kind == null) continue;
56+
57+
final body = responseForAction(
58+
kind: kind,
59+
actionId: actionId,
60+
input: input,
61+
);
62+
if (body == null) continue;
63+
plan.add(PendingReplay(rid, body));
64+
}
65+
return plan;
66+
}
67+
68+
/// Signature of the response sink — the `ConnectionController.respondTo`
69+
/// tear-off. Injected so the drainer stays unit-testable.
70+
typedef RespondTo = void Function(String requestId, Map<String, dynamic> body);
71+
72+
/// Drains the persisted force-quit pending-action queue exactly once.
73+
class PendingActionDrainer {
74+
PendingActionDrainer(this._prefs, this._respond);
75+
76+
final SharedPreferences _prefs;
77+
final RespondTo _respond;
78+
79+
/// Read the queue, replay each planned response in FIFO order, then clear the
80+
/// key. Best-effort + idempotent (a cleared queue makes a re-run a no-op; a
81+
/// double-replay is also absorbed by `respondTo`'s guard).
82+
Future<void> drain() async {
83+
final queue = _prefs.getStringList(kPendingActionsKey) ?? const <String>[];
84+
if (queue.isEmpty) return;
85+
for (final replay in planDrain(queue)) {
86+
_respond(replay.requestId, replay.body);
87+
}
88+
await _prefs.remove(kPendingActionsKey);
89+
}
90+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/// SPEC-07 Slice 2 — push-token registration seam.
2+
///
3+
/// [pushRegisterBody] is the pure `cmd` body the app sends after a successful
4+
/// (re)connect. [PushRegistrar] isolates native APNs/FCM token retrieval behind
5+
/// a platform channel/plugin; [NoopPushRegistrar] is the default (no token →
6+
/// the app never sends `push.register`, and the server falls back to Slice-1).
7+
library;
8+
9+
import 'package:flutter/foundation.dart';
10+
import 'package:flutter/services.dart';
11+
12+
import '../transport/protocol.dart';
13+
14+
/// Build the `push.register` command body. Pure + unit-tested.
15+
Map<String, dynamic> pushRegisterBody({
16+
required String token,
17+
required String platform,
18+
}) => {'kind': CmdKind.registerPush.wire, 'token': token, 'platform': platform};
19+
20+
/// Native push-token provider seam. A real implementation wraps a platform
21+
/// channel (iOS `AppDelegate` forwards the APNs token; Android/FCM later).
22+
abstract class PushRegistrar {
23+
/// The routing platform for this registrar: "apns" | "fcm".
24+
String get platform;
25+
26+
/// The current push token, or null when unavailable (permission declined,
27+
/// not yet retrieved, or no native provider wired).
28+
Future<String?> getToken();
29+
30+
/// Install a listener fired when a token first becomes (or newly becomes)
31+
/// available. The APNs token can arrive AFTER the socket connects, so the
32+
/// [ConnectionController] subscribes to this to send `push.register` late.
33+
/// Passing null detaches the listener.
34+
set onToken(void Function(String token)? listener);
35+
}
36+
37+
/// Default registrar: no native provider, so no token. Keeps the composition
38+
/// root buildable without platform wiring; the app simply skips registration
39+
/// and the server stays on the Slice-1 fallback.
40+
class NoopPushRegistrar implements PushRegistrar {
41+
const NoopPushRegistrar();
42+
43+
@override
44+
String get platform => 'apns';
45+
46+
@override
47+
Future<String?> getToken() async => null;
48+
49+
@override
50+
set onToken(void Function(String token)? listener) {
51+
// No native provider → a token never arrives, so nothing to notify.
52+
}
53+
}
54+
55+
/// A registrar whose token is provided by a caller (e.g. once the iOS
56+
/// `AppDelegate` channel delivers the APNs token). Kept trivial + testable.
57+
@immutable
58+
class ProvidedPushRegistrar implements PushRegistrar {
59+
const ProvidedPushRegistrar(this._token, {this.platform = 'apns'});
60+
61+
final String? _token;
62+
63+
@override
64+
final String platform;
65+
66+
@override
67+
Future<String?> getToken() async => _token;
68+
69+
@override
70+
set onToken(void Function(String token)? listener) {
71+
// Token is fixed at construction, so it never "newly" arrives.
72+
}
73+
}
74+
75+
/// Method-channel-backed registrar (SPEC-07 W4 Dart half). Listens on the
76+
/// `pino/push` channel that iOS `AppDelegate` invokes with the hex APNs token.
77+
///
78+
/// The token can arrive AFTER the socket connects, so this stores the latest
79+
/// token AND fires [onToken] so the [ConnectionController] can send
80+
/// `push.register` mid-connection. [getToken] returns the stored token (null
81+
/// until the native `didRegister` call fires).
82+
class ChannelPushRegistrar implements PushRegistrar {
83+
ChannelPushRegistrar({MethodChannel? channel, this.platform = 'apns'})
84+
: _channel = channel ?? const MethodChannel(pushChannelName) {
85+
_channel.setMethodCallHandler(_handle);
86+
}
87+
88+
/// The native channel name (mirrors `AppDelegate.swift`).
89+
static const String pushChannelName = 'pino/push';
90+
91+
/// The native method name for a delivered APNs token (mirrors AppDelegate).
92+
static const String didRegisterMethod = 'didRegister';
93+
94+
final MethodChannel _channel;
95+
96+
@override
97+
final String platform;
98+
99+
String? _token;
100+
void Function(String token)? _onToken;
101+
102+
@override
103+
Future<String?> getToken() async => _token;
104+
105+
@override
106+
set onToken(void Function(String token)? listener) => _onToken = listener;
107+
108+
Future<Object?> _handle(MethodCall call) async {
109+
if (call.method == didRegisterMethod) {
110+
final token = call.arguments;
111+
if (token is String && token.isNotEmpty) {
112+
_token = token;
113+
_onToken?.call(token);
114+
}
115+
}
116+
// `didFail` and unknown methods: ignore (best-effort seam; a missing token
117+
// simply leaves the app on the Slice-1 fallback).
118+
return null;
119+
}
120+
}

0 commit comments

Comments
 (0)