Commit 3c3cb85f authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: background run watcher and biometric lock

Batch 1 of the 60: the two that change what the app can be trusted with.

BACKGROUND WATCHER finishes a promise the app has implied since the first
build. The run always continued server-side — the phone is only a window onto
it — but nothing told you it had landed unless the chat screen happened to be
open. A foreground service now polls /api/chats and raises a local notification
when a chat leaves the running set.

Detecting completion by diffing successive polls is the only option available:
there is no FCM here and the server has nothing to push with. The service seeds
its set on start, so a run already in flight when it launches is not instantly
reported as finished. The background isolate cannot see the app's providers, so
it re-reads the server and token from SharedPreferences and makes its one GET
with dart:io rather than carrying Dio across the isolate boundary.

Off by default. Android requires a persistent notification for a foreground
service, and one the user did not ask for is how an app gets uninstalled — so it
is a switch, set to LOW importance because the ping is the point, not the badge.

BIOMETRIC LOCK, because this app can now push to repositories that deploy to
clients and redeploy their infrastructure, and a phone left on a table should be
able to do neither. It re-evaluates on every return to the foreground, not only
at cold start: someone picking up an already-running phone is the actual risk.
A grace window avoids prompting on every glance.

Two deliberate choices: the device PIN is accepted as a fallback rather than
biometrics only, and a PlatformException opens the app instead of blocking it.
Locking someone out of their own tooling because a fingerprint enrolment was
lost would be worse than the risk being guarded. This gates the UI; it is not a
vault, and does not claim to be.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 7f21d159
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Biometric lock.
///
/// This app can push to repositories that deploy to clients and redeploy their
/// infrastructure. A phone left unlocked on a table should not be able to do
/// either. The token itself is not encrypted by this — it is a gate on the UI,
/// not a vault — but it removes the "someone picked up my phone" case, which is
/// the realistic one.
///
/// Off by default: turning it on is a decision, and a lock nobody asked for on
/// first launch is a lock people disable.
abstract final class Lock {
static const _kEnabled = 'lock_enabled';
static const _kGraceSec = 'lock_grace_seconds';
static final _auth = LocalAuthentication();
static DateTime? _lastUnlock;
/// Whether the device can actually do this. Offering a lock that cannot
/// authenticate would leave someone unable to open their own app.
static Future<bool> available() async {
try {
if (!await _auth.isDeviceSupported()) return false;
return await _auth.canCheckBiometrics ||
(await _auth.getAvailableBiometrics()).isNotEmpty;
} on PlatformException {
return false;
}
}
static Future<bool> enabled() async {
final p = await SharedPreferences.getInstance();
return p.getBool(_kEnabled) ?? false;
}
static Future<void> setEnabled(bool v) async {
final p = await SharedPreferences.getInstance();
await p.setBool(_kEnabled, v);
if (v) _lastUnlock = null; // take effect immediately
}
/// How long the app may stay open after a successful unlock before asking
/// again. Zero means every return to the foreground re-prompts.
static Future<int> graceSeconds() async {
final p = await SharedPreferences.getInstance();
return p.getInt(_kGraceSec) ?? 120;
}
static Future<void> setGraceSeconds(int v) async {
final p = await SharedPreferences.getInstance();
await p.setInt(_kGraceSec, v.clamp(0, 3600));
}
/// True when the UI should be covered right now.
static Future<bool> shouldLock() async {
if (!await enabled()) return false;
final last = _lastUnlock;
if (last == null) return true;
return DateTime.now().difference(last).inSeconds >= await graceSeconds();
}
/// Prompts, and returns whether the app may be shown.
///
/// A platform error means the device cannot authenticate — from a lost
/// fingerprint enrolment, say. Locking someone out of their own tooling
/// because the sensor broke would be worse than the risk this guards, so an
/// unavailable authenticator opens rather than bricks.
static Future<bool> unlock() async {
try {
final ok = await _auth.authenticate(
localizedReason: 'Unlock Son of Anton',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: false, // device PIN is an acceptable fallback
useErrorDialogs: true,
),
);
if (ok) _lastUnlock = DateTime.now();
return ok;
} on PlatformException catch (e) {
debugPrint('biometric unavailable: ${e.code}');
_lastUnlock = DateTime.now();
return true;
}
}
/// Called when the app goes to the background, so returning re-evaluates.
static void markBackgrounded() {
// Nothing to clear: shouldLock() compares against the grace window, so
// simply leaving the timestamp alone is what makes the window mean anything.
}
}
import 'dart:async';
import 'dart:io';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'notifications.dart';
/// Watches for runs finishing while the app is closed.
///
/// This is the half of "send it, put the phone away, get told when it lands"
/// that was missing: the run always continued server-side, but nothing told you
/// it had finished unless the chat screen happened to be open.
///
/// A foreground service rather than push: there is no FCM here and the server
/// has nothing to push with. Android requires a persistent notification for
/// this, which is honest — the app is genuinely doing something while closed.
///
/// The isolate cannot see the app's providers, so it re-reads the base URL and
/// token from SharedPreferences and talks to the API itself.
@pragma('vm:entry-point')
void watcherCallback() {
FlutterForegroundTask.setTaskHandler(_Watcher());
}
class _Watcher extends TaskHandler {
/// Chat ids seen running on the previous tick. A run is "finished" when it
/// drops out of this set — comparing states between polls is the only way to
/// detect completion without the server pushing anything.
Set<String> _running = {};
String _base = '';
String _token = '';
@override
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {
final p = await SharedPreferences.getInstance();
_base = (p.getString('api_url') ?? '').trim();
_token = (p.getString('api_token') ?? '').trim();
// Seed from the current state so a run already in flight when the service
// starts is not immediately reported as finished.
_running = await _fetchRunning() ?? {};
}
@override
Future<void> onRepeatEvent(DateTime timestamp) async {
final now = await _fetchRunning();
if (now == null) return; // offline or server down; try again next tick
final finished = _running.difference(now);
for (final id in finished) {
final title = _titles[id] ?? 'Task finished';
await Notifications.runFinished(
chatTitle: title,
project: _projects[id] ?? '',
ok: _ok[id] ?? true,
);
}
_running = now;
}
final Map<String, String> _titles = {};
final Map<String, String> _projects = {};
final Map<String, bool> _ok = {};
Future<Set<String>?> _fetchRunning() async {
if (_base.isEmpty || _token.isEmpty) return null;
try {
final client = HttpClientShim();
final body = await client.getJson('$_base/api/chats', _token);
if (body is! List) return null;
final out = <String>{};
for (final e in body) {
if (e is! Map) continue;
final id = '${e['id']}';
final status = '${e['status']}';
_titles[id] = '${e['title'] ?? 'Chat'}';
_projects[id] = '${e['project'] ?? ''}';
_ok[id] = status != 'failed';
if (status == 'running') out.add(id);
}
return out;
} catch (e) {
debugPrint('watcher poll failed: $e');
return null;
}
}
@override
Future<void> onDestroy(DateTime timestamp) async {}
}
/// Minimal HTTP for the background isolate. Dio is not carried in here: the
/// isolate needs one GET, and a second HTTP stack is a second thing to configure
/// with timeouts and headers.
class HttpClientShim {
Future<dynamic> getJson(String url, String token) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 15);
try {
final req = await client.getUrl(Uri.parse(url));
req.headers.set('Authorization', 'Bearer $token');
final res = await req.close().timeout(const Duration(seconds: 25));
if (res.statusCode != 200) return null;
return jsonDecode(await res.transform(utf8.decoder).join());
} finally {
client.close(force: true);
}
}
}
/// Starts and stops the watcher. Off by default — a persistent notification the
/// user did not ask for is how an app gets uninstalled.
abstract final class Watcher {
static const _kEnabled = 'watch_enabled';
static Future<bool> enabled() async {
final p = await SharedPreferences.getInstance();
return p.getBool(_kEnabled) ?? false;
}
static Future<void> init() async {
FlutterForegroundTask.initCommunicationPort();
FlutterForegroundTask.init(
androidNotificationOptions: AndroidNotificationOptions(
channelId: 'watcher',
channelName: 'Run watcher',
channelDescription: 'Watches for agent runs finishing while the app is closed.',
channelImportance: NotificationChannelImportance.LOW, // quiet: the ping is the point, not this
priority: NotificationPriority.LOW,
),
iosNotificationOptions: const IOSNotificationOptions(),
foregroundTaskOptions: ForegroundTaskOptions(
eventAction: ForegroundTaskEventAction.repeat(20000),
autoRunOnBoot: false,
allowWakeLock: true,
allowWifiLock: false,
),
);
}
static Future<void> setEnabled(bool on) async {
final p = await SharedPreferences.getInstance();
await p.setBool(_kEnabled, on);
on ? await start() : await stop();
}
static Future<void> start() async {
if (await FlutterForegroundTask.isRunningService) return;
await FlutterForegroundTask.startService(
notificationTitle: 'Son of Anton',
notificationText: 'Watching for finished runs',
callback: watcherCallback,
);
}
static Future<void> stop() async {
if (await FlutterForegroundTask.isRunningService) {
await FlutterForegroundTask.stopService();
}
}
}
......@@ -2,15 +2,44 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/models.dart';
import '../../core/lock.dart';
import '../../core/providers.dart';
import '../../core/watcher.dart';
import '../../core/theme.dart';
import '../../widgets/flame_mark.dart';
class SettingsScreen extends ConsumerWidget {
class SettingsScreen extends ConsumerStatefulWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends ConsumerState<SettingsScreen> {
bool _lockOn = false;
bool _lockAvailable = false;
bool _watchOn = false;
@override
void initState() {
super.initState();
_loadToggles();
}
Future<void> _loadToggles() async {
final a = await Lock.available();
final l = await Lock.enabled();
final w = await Watcher.enabled();
if (!mounted) return;
setState(() {
_lockAvailable = a;
_lockOn = l;
_watchOn = w;
});
}
@override
Widget build(BuildContext context) {
final session = ref.watch(sessionProvider);
final usage = ref.watch(usageProvider);
......@@ -71,6 +100,45 @@ class SettingsScreen extends ConsumerWidget {
),
),
const SizedBox(height: 20),
const _SectionLabel('Background'),
Card(
child: SwitchListTile(
value: _watchOn,
activeThumbColor: Ember.flame,
title: const Text('Notify when a run finishes',
style: TextStyle(fontSize: 14)),
subtitle: const Text(
'Watches while the app is closed. Android shows a quiet permanent notification while this is on.',
style: TextStyle(fontSize: 11, color: Ember.textFaint),
),
onChanged: (v) async {
setState(() => _watchOn = v);
await Watcher.setEnabled(v);
},
),
),
const SizedBox(height: 20),
const _SectionLabel('Security'),
Card(
child: SwitchListTile(
value: _lockOn,
activeThumbColor: Ember.flame,
title: const Text('Require unlock', style: TextStyle(fontSize: 14)),
subtitle: Text(
_lockAvailable
? 'Biometric or device PIN on open. This app can push to client repos and redeploy their infrastructure.'
: 'No biometric or device lock is set up on this phone.',
style: const TextStyle(fontSize: 11, color: Ember.textFaint),
),
onChanged: _lockAvailable
? (v) async {
setState(() => _lockOn = v);
await Lock.setEnabled(v);
}
: null,
),
),
const SizedBox(height: 20),
const _SectionLabel('Connection'),
Card(
child: Column(
......
......@@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/lock.dart';
import 'core/notifications.dart';
import 'core/watcher.dart';
import 'core/providers.dart';
import 'core/theme.dart';
import 'features/auth/login_screen.dart';
import 'features/shell/home_shell.dart';
import 'widgets/flame_mark.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
......@@ -21,6 +24,8 @@ Future<void> main() async {
systemNavigationBarIconBrightness: Brightness.light,
));
await Notifications.init();
await Watcher.init();
if (await Watcher.enabled()) await Watcher.start();
runApp(const ProviderScope(child: SonOfAntonApp()));
}
......@@ -36,7 +41,96 @@ class SonOfAntonApp extends ConsumerWidget {
theme: buildTheme(),
// Deliberately not go_router: this app has one auth gate and a tab shell.
// A declarative router here would be indirection with nothing to route.
home: session.isAuthed ? const HomeShell() : const LoginScreen(),
home: session.isAuthed ? const _Guarded(child: HomeShell()) : const LoginScreen(),
);
}
}
/// Biometric gate in front of the app.
///
/// Re-checks whenever the app returns to the foreground, because the risk it
/// guards against is someone picking up an already-running phone — locking only
/// at cold start would miss exactly that.
class _Guarded extends StatefulWidget {
const _Guarded({required this.child});
final Widget child;
@override
State<_Guarded> createState() => _GuardedState();
}
class _GuardedState extends State<_Guarded> with WidgetsBindingObserver {
bool _locked = false;
bool _asking = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_evaluate();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _evaluate();
}
Future<void> _evaluate() async {
if (_asking) return;
if (!await Lock.shouldLock()) {
if (mounted && _locked) setState(() => _locked = false);
return;
}
if (mounted) setState(() => _locked = true);
_asking = true;
final ok = await Lock.unlock();
_asking = false;
if (mounted) setState(() => _locked = !ok);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
widget.child,
if (_locked)
Positioned.fill(
child: ColoredBox(
color: Ember.bg,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const FlameMark(size: 64),
const SizedBox(height: 20),
const Text('Locked',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
const Text('This app can deploy to production.',
style: TextStyle(fontSize: 12, color: Ember.textDim)),
const SizedBox(height: 22),
OutlinedButton.icon(
onPressed: _evaluate,
icon: const Icon(Icons.fingerprint, size: 18),
label: const Text('Unlock'),
style: OutlinedButton.styleFrom(
foregroundColor: Ember.flame,
side: const BorderSide(color: Ember.flame),
),
),
],
),
),
),
),
],
);
}
}
......@@ -552,6 +552,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
local_auth:
dependency: "direct main"
description:
name: local_auth
sha256: "434d854cf478f17f12ab29a76a02b3067f86a63a6d6c4eb8fbfdcfe4879c1b7b"
url: "https://pub.dev"
source: hosted
version: "2.3.0"
local_auth_android:
dependency: transitive
description:
name: local_auth_android
sha256: a0bdfcc0607050a26ef5b31d6b4b254581c3d3ce3c1816ab4d4f4a9173e84467
url: "https://pub.dev"
source: hosted
version: "1.0.56"
local_auth_darwin:
dependency: transitive
description:
name: local_auth_darwin
sha256: "699873970067a40ef2f2c09b4c72eb1cfef64224ef041b3df9fdc5c4c1f91f49"
url: "https://pub.dev"
source: hosted
version: "1.6.1"
local_auth_platform_interface:
dependency: transitive
description:
name: local_auth_platform_interface
sha256: f98b8e388588583d3f781f6806e4f4c9f9e189d898d27f0c249b93a1973dd122
url: "https://pub.dev"
source: hosted
version: "1.1.0"
local_auth_windows:
dependency: transitive
description:
name: local_auth_windows
sha256: bc4e66a29b0fdf751aafbec923b5bed7ad6ed3614875d8151afe2578520b2ab5
url: "https://pub.dev"
source: hosted
version: "1.0.11"
logging:
dependency: transitive
description:
......
......@@ -44,6 +44,10 @@ dependencies:
share_plus: ^10.1.3
path_provider: ^2.1.5
# Biometric lock. This app can push to client repos and redeploy their
# infrastructure; an unlocked phone should not be able to.
local_auth: ^2.3.0
# Storage + misc
shared_preferences: ^2.3.3
connectivity_plus: ^6.1.0
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment