Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import 'dart:async';

import 'package:app_links/app_links.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'
show FlutterLocalNotificationsPlugin;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
Expand Down Expand Up @@ -35,6 +37,7 @@ import 'providers/drawer_order.dart';
import 'providers/first_run.dart';
import 'providers/host_status.dart';
import 'providers/local_host_warmup.dart';
import 'providers/notification_route_apply.dart';
import 'providers/post_signin_provisioning.dart';
import 'providers/projects.dart';
import 'providers/provider_retry.dart';
Expand All @@ -43,6 +46,7 @@ import 'providers/recent_agents.dart';
import 'navigation/nav_console.dart';
import 'navigation/nav_controller.dart';
import 'navigation/nav_serialization.dart';
import 'navigation/notification_route.dart';
import 'navigation/platform_route_guard.dart';
import 'navigation/root_navigator.dart';
import 'screens/app_shell.dart';
Expand All @@ -51,7 +55,10 @@ import 'screens/device_cap_dialog.dart';
import 'screens/sign_in_screen.dart';
import 'services/devices_api.dart' show DeviceCapInfo;
import 'services/app_settings_service.dart';
import 'services/local_notification_service.dart';
import 'services/notification_tap.dart';
import 'services/push_background_handler.dart';
import 'services/push_identity.dart';
import 'providers/update_available.dart';
import 'storage/cached_sessions_store.dart';
import 'storage/drawer_collapsed_store.dart';
Expand All @@ -62,6 +69,7 @@ import 'storage/recent_agents_store.dart';
import 'storage/update_handoff_store.dart';
import 'update/update_gate.dart';
import 'util/ab_log.dart';
import 'util/detached.dart';
import 'widgets/auth_splash.dart';
import 'widgets/demo_frame.dart';
import 'window/window_chrome.dart';
Expand Down Expand Up @@ -310,6 +318,42 @@ Future<void> main() async {
// Subscribe for in-flight links while the app is running.
appLinks.uriLinkStream.listen((uri) => unawaited(handleLink(uri)));

// The one place a tapped notification is turned into a navigation, whichever
// surface delivered it. No context: an OS-level tap has no widget behind it,
// so the applier falls back to the root navigator.
Future<void> handleNotificationRoute(NotificationRoute? route) async {
if (route == null) return;
await applyNotificationRoute(null, container, route);
}

// Unconditional and platform-independent — a desktop tap is worth as much as
// a phone one. Registering it is not evidence a tap can ever arrive: fln
// installs its method-call handler only inside `initialize`, which the demo's
// shell mount deliberately skips, so an install that has only ever shown the
// sample project has no warm-tap channel at all. Nothing is logged here.
LocalNotificationService().onTap = (response) => detached(
'Main',
'notification tap failed',
() => handleNotificationRoute(routeOfTapResponse(response)),
);

// iOS ONLY — see [pushTapRegistrationSupported] for why the narrowing is the
// substance. Even on iOS this callback is not ours alone: push forwards fln's
// local-notification userInfo down it with no source filter, which is why a
// map that decodes to nothing returns quietly rather than logging.
if (pushTapRegistrationSupported(defaultTargetPlatform)) {
Push.instance.addOnNotificationTap(
(data) => detached('Main', 'push notification tap failed', () async {
final decoded = await decodePush(
pushDataMap(data),
pushIdentity: PushIdentity.secure(),
);
if (decoded == null) return;
await handleNotificationRoute(routeOfPush(decoded));
}),
);
}

analytics.track(AnalyticsEvents.appActive);
WidgetsBinding.instance.addObserver(
_TelemetryLifecycleObserver(onPause: analytics.flush),
Expand All @@ -331,6 +375,53 @@ Future<void> main() async {
// half-open connection. It's best-effort and invalidates currentUserProvider
// when it completes, so the UI updates as soon as the session lands.
unawaited(appLinks.getInitialLink().then(handleLink));

// The notification that launched a terminated app. Each source is read
// EXACTLY once, because none of them is drained by the read: push's
// terminated-tap value is a static it never clears, fln's iOS
// launch-response dict is never emptied, and Android's `onNewIntent` re-arms
// the launch intent — so a later re-read is a guaranteed duplicate rather
// than a retry. Both iOS sources are read, because either can be the one that
// fired (push's skip guard keys on a launch option an fln-launched start
// never sets) and the applier's value-dedup absorbs the overlap.
detached('Main', 'cold-start notification tap failed', () async {
// `runApp` only SCHEDULES the first frame, so the root navigator is not
// attached yet. The applier's cross-project path hands its route to a
// pending slot and then pushes on that navigator; reading before it exists
// spends the route on nothing, with no retry behind it.
await WidgetsBinding.instance.endOfFrame;
// Platform-gated (see [launchDetailsSupported]) AND wrapped: the gate keeps
// the known-broken platforms out, the try keeps an unknown one from taking
// the whole cold-start block — the push read below it included — with it.
if (launchDetailsSupported(defaultTargetPlatform)) {
try {
final details = await FlutterLocalNotificationsPlugin()
.getNotificationAppLaunchDetails();
// The response is the gate, not `didNotificationLaunchApp`: a route
// needs a payload, and only the response carries one.
final response = details?.notificationResponse;
if (response != null) {
await handleNotificationRoute(routeOfTapResponse(response));
}
} catch (e) {
AbLog.error(
'Main',
'notification launch details failed',
fields: {'error': '$e'},
);
}
}
if (defaultTargetPlatform != TargetPlatform.iOS) return;
final tapped =
await Push.instance.notificationTapWhichLaunchedAppFromTerminated;
if (tapped == null) return;
final decoded = await decodePush(
pushDataMap(tapped),
pushIdentity: PushIdentity.secure(),
);
if (decoded == null) return;
await handleNotificationRoute(routeOfPush(decoded));
});
}

class _TelemetryLifecycleObserver extends WidgetsBindingObserver {
Expand Down
11 changes: 11 additions & 0 deletions app/lib/navigation/nav_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,11 @@ class NavController extends Notifier<NavState> {
// clears any stale pending from an earlier switch, so the new project's
// bootstrap can't consume a session id meant for a different project.
ref.read(pendingActiveSessionIdProvider.notifier).set(loc.sessionId);
// Cleared with the id it qualifies, never left behind: back()/forward()
// and a deep link all mean "resume this", and a suppressor outliving the
// queue it was written for would silently re-pair with a later
// Recent-list tap on the same session and eat the start that tap IS.
ref.read(pendingSessionStartSuppressedIdProvider.notifier).set(null);
} else if (loc.target == currentTarget && loc.sessionId != null) {
// Same project: bootstrap won't re-run, so select directly.
ref.read(activeSessionIdProvider.notifier).set(loc.sessionId);
Expand Down Expand Up @@ -183,6 +188,12 @@ class NavController extends Notifier<NavState> {
.set(
loc.file == null ? null : (target: pendingTarget, value: loc.file!),
);
// A [NavLocation] can never ASK for the agent transcript — there is no
// WorkspaceView for it — so this is only ever the null half. It belongs
// here all the same: the drains run in one post-frame callback with this
// one last, so a stamp an earlier location left would otherwise override
// the tab a back()/forward()/deep link just named.
ref.read(pendingAgentPageProvider.notifier).set(null);
}
}

Expand Down
233 changes: 233 additions & 0 deletions app/lib/navigation/notification_route.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
// app/lib/navigation/notification_route.dart
import 'dart:convert';

import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';

import '../models/recent_session_row.dart';
import '../models/session_target.dart';
import '../models/workspace_view.dart';
import '../providers/ui_attention_providers.dart' show WorkbenchSurface;
import '../util/device_id.dart';
import 'nav_location.dart';

/// What a tapped notification names — the wire shape only. Turning one into a
/// place the app can go is [resolveNotificationRoute], which refuses rather
/// than guesses.
///
/// Every field is optional because every producer names a different subset: a
/// live stream already holds the drawer entryId, a sealed push carries
/// machine + project + session, and a hook notification may name nothing but a
/// title (`sessionId` is optional on `notification:push`).
@immutable
class NotificationRoute {
/// Pre-resolved drawer entry id, in its local-or-remote shape. Set by the
/// in-app paths, which already know which entry the notification arrived on.
final String? registrationId;

/// Sealed-push path only, and null from a bridge that predates the widened
/// payload — which is why an absent one is unroutable, never guessed at.
final String? machineUuid;
final String? projectId;
final String? terminalId;
final String? sourceMessageId;

/// `agent` or `handler`. Anything else is treated as `agent`: an unknown kind
/// from a newer bridge must land the user on the session, not nowhere.
final String? kind;

const NotificationRoute({
this.registrationId,
this.machineUuid,
this.projectId,
this.terminalId,
this.sourceMessageId,
this.kind,
});

/// Value equality is load-bearing, not a convenience: the applier dedups on
/// it, because [sourceMessageId] is nullable by design and a route without
/// one would otherwise have no dedup key at all.
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is NotificationRoute &&
other.registrationId == registrationId &&
other.machineUuid == machineUuid &&
other.projectId == projectId &&
other.terminalId == terminalId &&
other.sourceMessageId == sourceMessageId &&
other.kind == kind;

@override
int get hashCode => Object.hash(
registrationId,
machineUuid,
projectId,
terminalId,
sourceMessageId,
kind,
);

@override
String toString() =>
'NotificationRoute(registrationId: $registrationId, '
'machineUuid: $machineUuid, projectId: $projectId, '
'terminalId: $terminalId, sourceMessageId: $sourceMessageId, '
'kind: $kind)';
}

/// Serializes [r] for a payload slot that only carries a string — an OS
/// notification's action payload.
///
/// A null field is OMITTED, never emitted as `""`: the two mean different
/// things downstream (an empty id names no project, but would still satisfy a
/// `!= null` test), so the encoding must not be able to manufacture one.
String encodeNotificationRoute(NotificationRoute r) => jsonEncode({
if (r.registrationId != null) 'registrationId': r.registrationId,
if (r.machineUuid != null) 'machineUuid': r.machineUuid,
if (r.projectId != null) 'projectId': r.projectId,
if (r.terminalId != null) 'terminalId': r.terminalId,
if (r.sourceMessageId != null) 'sourceMessageId': r.sourceMessageId,
if (r.kind != null) 'kind': r.kind,
});

/// Parses a payload back into a route, or null when there is nothing to parse.
///
/// The payload arrives from the OS notification the user tapped and is applied
/// fire-and-forget, so a throw here would be an unhandled async error: anything
/// that is not a JSON object degrades to null, and a value of the wrong type
/// degrades to an absent field rather than failing the whole route.
NotificationRoute? decodeNotificationRoute(String? payload) {
if (payload == null) return null;
final Object? decoded;
try {
decoded = jsonDecode(payload);
} catch (_) {
return null;
}
if (decoded is! Map) return null;
final route = NotificationRoute(
registrationId: namedOrNull(decoded['registrationId']),
machineUuid: namedOrNull(decoded['machineUuid']),
projectId: namedOrNull(decoded['projectId']),
terminalId: namedOrNull(decoded['terminalId']),
sourceMessageId: namedOrNull(decoded['sourceMessageId']),
kind: namedOrNull(decoded['kind']),
);
// A payload naming NOTHING is the same answer as no payload, and must reach
// the applier as one: `{}` is a valid JSON object, so without this it would
// arrive as a non-null route and spend the applier's unconditional
// side effects — leaving the demo, above all — on a tap that then resolves
// to no destination at all.
return route == const NotificationRoute() ? null : route;
}

/// The value of a field that actually names something, or null.
///
/// Blank and absent collapse deliberately: `LocalProject('')` is a target the
/// app would try to focus, so an empty id must not survive far enough to be
/// tested for null.
///
/// Public because the sealed-push decoder (`services/push_background_handler.
/// dart`) has to answer identically: an id only one of them accepts is an id
/// that survives decoding to address nothing. One definition is what makes that
/// structural rather than a comment.
String? namedOrNull(Object? value) {
if (value is! String) return null;
final trimmed = value.trim();
// The TRIMMED value, not the original: testing `trim()` and returning the
// padding would let " <uuid>" pass as naming something and then be used
// verbatim as a machine id, matching no drawer entry and no cached origin.
return trimmed.isEmpty ? null : trimmed;
}

/// Resolves [route] to a place, or null when nothing can be addressed without
/// guessing.
///
/// [known] is the cached-session universe (`recentSessionsProvider`);
/// [localDeviceUuid] is this install's machine uuid, null on a platform that
/// hosts no projects — which is why locality is decided per row against that
/// uuid and never by a platform test.
NavLocation? resolveNotificationRoute(
NotificationRoute route, {
required List<RecentSessionRow> known,
required String? localDeviceUuid,
}) {
final target = _resolveTarget(
route,
known: known,
localDeviceUuid: localDeviceUuid,
);
if (target == null) return null;
return NavLocation(
target: target,
surface: WorkbenchSurface.workspace,
// Carried whenever the route names a session; whether that session is still
// open is the applier's question, not this one's.
sessionId: namedOrNull(route.terminalId),
view: namedOrNull(route.kind) == 'handler' ? WorkspaceView.handler : null,
);
}

SessionTarget? _resolveTarget(
NotificationRoute route, {
required List<RecentSessionRow> known,
required String? localDeviceUuid,
}) {
final registrationId = namedOrNull(route.registrationId);
if (registrationId != null) {
// A cached row is preferred over splitting the id ourselves because the row
// was classified against the real project list: a bare id belonging to no
// local project is still local, and only the row knows that.
final row = known.firstWhereOrNull(
(r) => r.origin.registrationId == registrationId,
);
if (row != null) return _targetOf(row.origin);
return _splitRegistrationId(registrationId);
}

final machineUuid = namedOrNull(route.machineUuid);
final projectId = namedOrNull(route.projectId);
if (machineUuid != null && projectId != null) {
if (machineUuid == localDeviceUuid) return LocalProject(projectId);
return RemoteProject(machineUuid: machineUuid, projectId: projectId);
}

final terminalId = namedOrNull(route.terminalId);
if (terminalId != null) {
// Session ids are uuids minted per session, so a hit names exactly one
// project — but only if there IS one hit. Distinct registration ids, not
// rows: the same session listed twice under one project is not ambiguous.
final origins = <String, RecentOrigin>{};
for (final row in known) {
if (row.session.id != terminalId) continue;
origins[row.origin.registrationId] = row.origin;
}
if (origins.length == 1) return _targetOf(origins.values.first);
return null;
}

// Deliberately no projectId-only fallback. `computeProjectId` hashes the
// folder path with no machine input, so the same repo checked out at the same
// path on two machines mints the identical id — a "unique" match there is a
// confident wrong machine. Unroutable is the honest answer.
return null;
}

SessionTarget _targetOf(RecentOrigin origin) {
final machineUuid = origin.machineUuid;
if (origin.isLocal || machineUuid == null) {
return LocalProject(origin.projectId);
}
return RemoteProject(machineUuid: machineUuid, projectId: origin.projectId);
}

SessionTarget _splitRegistrationId(String registrationId) {
final machineUuid = baseDeviceUuid(registrationId);
if (machineUuid == registrationId) return LocalProject(registrationId);
return RemoteProject(
machineUuid: machineUuid,
projectId: baseProjectId(registrationId),
);
}
Loading