phone_app: use the shared github_device_auth package

The inline `_DeviceCodeDialog` (86 lines) was byte-for-byte identical to
diet-guard's, and `services/github_device_auth.dart` was a fourth copy of
the auth client. Both now come from `github_device_auth` in the
kuhyx/utils monorepo.

This app's client was not merely a drifted copy -- it carried a fix the
other three lacked: `pollForToken` swallowed a transient socket failure and
kept polling, because GitHub can close the connection at the exact moment
the user authorizes. Extracting todo's version would have silently deleted
that. The package took this behaviour instead, so the fix now protects all
four apps rather than just this one.

It is expressed as `on http.ClientException` rather than the local
`SocketException` arm, because the package is also imported by
diet-guard's **web** build where `dart:io` will not compile. Verified that
nothing is lost: package:http wraps a dead socket as
`_ClientSocketException`, which implements both types.

19 tests pass, analyze clean, and the release APK is installed on the
phone and launching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U4Rj3s7K2qzrLNgJqqPhno
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 16:16:17 +02:00
parent 59cc9bffef
commit b9abbf7043
5 changed files with 32 additions and 265 deletions

View File

@ -10,6 +10,14 @@ analyzer:
unnecessary_null_comparison: error
dead_code: error
invalid_annotation_target: error
exclude:
- build/**
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:

View File

@ -1,10 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wake_alarm_sync/services/github_device_auth.dart';
import 'package:github_device_auth/github_device_auth.dart';
import 'package:wake_alarm_sync/services/sync_settings.dart';
/// The GitHub mirror screen: cutover-only sync transport, not a peer of
@ -46,7 +43,7 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
final token = await showDialog<String>(
context: context,
barrierDismissible: false,
builder: (_) => _DeviceCodeDialog(device: device, auth: auth),
builder: (_) => DeviceCodeDialog(device: device, auth: auth),
);
if (token == null || token.isEmpty) return;
@ -152,89 +149,3 @@ class _GitHubMirrorScreenState extends State<GitHubMirrorScreen> {
);
}
}
/// Dialog shown while waiting for the user to authorize on GitHub.
class _DeviceCodeDialog extends StatefulWidget {
const _DeviceCodeDialog({required this.device, required this.auth});
final DeviceCodeResponse device;
final GitHubDeviceAuth auth;
@override
State<_DeviceCodeDialog> createState() => _DeviceCodeDialogState();
}
class _DeviceCodeDialogState extends State<_DeviceCodeDialog> {
String? _error;
@override
void initState() {
super.initState();
unawaited(_poll());
}
Future<void> _poll() async {
try {
final token = await widget.auth.pollForToken(widget.device);
if (mounted) Navigator.of(context).pop(token);
} on Exception catch (e) {
if (mounted) setState(() => _error = '$e');
}
}
Future<void> _openPage() async {
await Clipboard.setData(ClipboardData(text: widget.device.userCode));
await launchUrl(
Uri.parse(widget.device.verificationUri),
mode: LaunchMode.externalApplication,
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Authorize on GitHub'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Enter this code on GitHub:'),
const SizedBox(height: 8),
SelectableText(
widget.device.userCode,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 16),
if (_error == null)
const Row(
children: [
SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Expanded(child: Text('Waiting for authorization…')),
],
)
else
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: _openPage,
icon: const Icon(Icons.open_in_new),
label: const Text('Open GitHub & copy code'),
),
],
);
}
}

View File

@ -1,166 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
/// First-stage response of the GitHub OAuth Device Flow.
class DeviceCodeResponse {
/// Creates a [DeviceCodeResponse].
const DeviceCodeResponse({
required this.deviceCode,
required this.userCode,
required this.verificationUri,
required this.interval,
required this.expiresIn,
});
/// Creates a [DeviceCodeResponse] from a GitHub API JSON response.
factory DeviceCodeResponse.fromJson(Map<String, dynamic> json) {
return DeviceCodeResponse(
deviceCode: json['device_code'] as String,
userCode: json['user_code'] as String,
verificationUri: json['verification_uri'] as String,
interval: (json['interval'] as int?) ?? 5,
expiresIn: (json['expires_in'] as int?) ?? 900,
);
}
/// Opaque code the client polls with (not shown to the user).
final String deviceCode;
/// Short code the user enters on the verification page.
final String userCode;
/// Page the user opens to enter [userCode].
final String verificationUri;
/// Minimum seconds to wait between polls.
final int interval;
/// Seconds until [deviceCode] expires.
final int expiresIn;
}
/// Raised when the device-flow authorization fails or is declined.
class DeviceAuthException implements Exception {
/// Creates a [DeviceAuthException].
DeviceAuthException(this.code, this.message);
/// GitHub error code, e.g. `access_denied`, `expired_token`.
final String code;
/// Human-readable description of the error.
final String message;
@override
String toString() => 'DeviceAuthException($code): $message';
}
/// Implements the GitHub OAuth **Device Flow** so the user can authorize the
/// app by visiting a URL and entering a short code — no token pasting.
///
/// Device flow needs only a public `client_id` (no client secret), which
/// makes it safe for a distributed app. The resulting access token is then
/// used exactly like a PAT by the crdt_sync GitHub-Contents transport.
class GitHubDeviceAuth {
/// Creates a [GitHubDeviceAuth] instance.
GitHubDeviceAuth({
required this.clientId,
this.scope = 'repo',
http.Client? httpClient,
Future<void> Function(Duration)? delay,
}) : _http = httpClient ?? http.Client(),
_delay = delay ?? Future<void>.delayed;
/// OAuth App client ID (public identifier, not a secret).
final String clientId;
/// OAuth scope requested. `repo` is required for the Contents API to
/// read/write the private `kuhyx/syncs` repo (Gists are no longer used).
final String scope;
final http.Client _http;
final Future<void> Function(Duration) _delay;
static const _deviceCodeUrl =
'https://github.com/login/device/code';
static const _tokenUrl =
'https://github.com/login/oauth/access_token';
static const _grantType =
'urn:ietf:params:oauth:grant-type:device_code';
/// Step 1: ask GitHub for a device + user code.
Future<DeviceCodeResponse> requestDeviceCode() async {
final res = await _http.post(
Uri.parse(_deviceCodeUrl),
headers: const {'Accept': 'application/json'},
body: {'client_id': clientId, 'scope': scope},
);
if (res.statusCode != 200) {
throw DeviceAuthException('http_${res.statusCode}', res.body);
}
return DeviceCodeResponse.fromJson(
jsonDecode(res.body) as Map<String, dynamic>,
);
}
/// Step 2: poll until the user authorizes, returning the access token.
///
/// Honors GitHub's pacing protocol: `authorization_pending` keeps
/// polling, `slow_down` increases the interval, and terminal errors
/// throw a [DeviceAuthException].
Future<String> pollForToken(DeviceCodeResponse device) async {
var intervalSeconds = device.interval;
final deadline = DateTime.now().add(
Duration(seconds: device.expiresIn),
);
while (DateTime.now().isBefore(deadline)) {
await _delay(Duration(seconds: intervalSeconds));
http.Response res;
try {
res = await _http.post(
Uri.parse(_tokenUrl),
headers: const {'Accept': 'application/json'},
body: {
'client_id': clientId,
'device_code': device.deviceCode,
'grant_type': _grantType,
},
);
} on http.ClientException catch (_) {
// GitHub can close the connection after the user authorizes; retry.
continue;
} on SocketException catch (_) {
continue;
}
final json = jsonDecode(res.body) as Map<String, dynamic>;
final token = json['access_token'] as String?;
if (token != null) return token;
switch (json['error'] as String?) {
case 'authorization_pending':
continue;
case 'slow_down':
intervalSeconds =
(json['interval'] as int?) ?? intervalSeconds + 5;
case final String error:
throw DeviceAuthException(
error,
(json['error_description'] as String?) ?? error,
);
case null:
throw DeviceAuthException(
'unknown',
'Unexpected response: $json',
);
}
}
throw DeviceAuthException('expired_token', 'Device code expired.');
}
/// Closes the underlying HTTP client.
void close() => _http.close();
}

View File

@ -217,6 +217,15 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
github_device_auth:
dependency: "direct main"
description:
path: github_device_auth
ref: "github_device_auth-v0.3.0"
resolved-ref: "254e3b00c7f41a54c588ec32564017f96f328252"
url: "https://github.com/kuhyx/utils"
source: git
version: "0.3.0"
google_identity_services_web:
dependency: transitive
description:
@ -365,10 +374,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
@ -381,10 +390,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.19.0"
objective_c:
dependency: transitive
description:
@ -619,10 +628,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
typed_data:
dependency: transitive
description:
@ -707,10 +716,10 @@ packages:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.2.0"
version: "2.4.2"
very_good_analysis:
dependency: "direct dev"
description:

View File

@ -21,6 +21,11 @@ dependencies:
# One-tap sign-in for the sync account. App-side only: crdt_sync stays pure
# Dart so it keeps working on Linux desktop and headless.
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.3.0
path: github_device_auth
google_sign_in: ^7.2.0
http: ^1.2.0
path: ^1.9.0