github_device_auth: extract the GitHub device flow from four apps

todo, home_inventory, diet-guard and wake-alarm each carried the OAuth
device-flow client and the modal that drives it -- ~650 lines, with two
pairs byte-for-byte identical (todo/home_inventory's clients, and
diet-guard/wake-alarm's dialogs).

Deliberately its own package rather than part of `sync_settings_ui`. That
package is Firebase-only by an explicit 2026-08-12 decision recorded as
"do not re-add a GitHub section without kuhy asking again", so folding a
GitHub flow into it would reverse a standing decision. The GitHub mirror
in these apps is legacy-but-load-bearing code that was explicitly not
authorised for deletion, so it gets its own home instead of being merged
into the Firebase path or dropped.

One behaviour change, made once here instead of four times: the dialog
catches `on Object`, not `on Exception`. An `Error` is not an `Exception`
in Dart, so `ArgumentError` -- notably from URI parsing -- escaped
`_poll()` and left the dialog spinning on "Waiting for authorization…"
forever with nothing shown. That is the same trap that silently killed the
Firebase sync tick across these apps in 2026-08. A test pins it with a
mock that throws an Error rather than an Exception, so it cannot regress
back to `on Exception` unnoticed.

20 tests, 100% line coverage, clean under very_good_analysis, with the
same CI gate the other Dart package holds. Consumers are migrated
separately so each app's own suite gates its own swap.

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 15:57:19 +02:00
parent 41f014e7a7
commit 795a8eae62
12 changed files with 1226 additions and 0 deletions

View File

@ -0,0 +1,36 @@
name: github_device_auth tests
on:
push:
branches: [main]
paths: ['github_device_auth/**']
pull_request:
branches: [main]
paths: ['github_device_auth/**']
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: github_device_auth
steps:
- uses: actions/checkout@v4
- name: Set up Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
- name: Install dependencies
run: flutter pub get
- name: Analyze
run: flutter analyze
- name: Run tests with coverage
run: flutter test --coverage
- name: Enforce 100% line coverage
run: scripts/check_coverage.sh coverage/lcov.info 100

3
github_device_auth/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
build/
coverage/
.dart_tool/

View File

@ -0,0 +1,69 @@
# `github_device_auth`
GitHub OAuth **Device Flow** for kuhy's Flutter apps: the auth client and the
modal that drives it.
Device flow needs only a public `client_id` (no client secret), which is what
makes it safe for a distributed app. The resulting access token is then used
exactly like a PAT.
## Why this is its own package
Four apps each carried a copy of both halves:
| App | Client | Dialog |
| --- | --- | --- |
| todo | `lib/sync/github_device_auth.dart` (154) | `lib/ui/device_code_dialog.dart` (110) |
| home_inventory | `lib/sync/github_device_auth.dart` (154, **byte-identical** to todo's) | inline |
| diet-guard | `app/lib/services/github_device_auth.dart` (175) | inline (~84, **byte-identical** to wake-alarm's) |
| wake-alarm | `phone_app/lib/services/github_device_auth.dart` (166) | inline (~84) |
~650 lines, two pairs of them byte-for-byte identical.
**It is deliberately not part of `sync_settings_ui`.** That package is
Firebase-only by an explicit decision (2026-08-12), recorded with "do not
re-add a GitHub section without kuhy asking again". Folding a GitHub flow into
it would reverse that. The GitHub mirror in these apps is legacy-but-
load-bearing code, so it gets its own home rather than being merged into the
Firebase path — or deleted, which was never authorised.
## Use
```yaml
dependencies:
github_device_auth:
git:
url: https://github.com/kuhyx/utils
ref: github_device_auth-v0.1.0
path: github_device_auth
```
```dart
final auth = GitHubDeviceAuth(clientId: '<oauth app client id>');
final device = await auth.requestDeviceCode();
final token = await showDialog<String>(
context: context,
builder: (_) => DeviceCodeDialog(device: device, auth: auth),
);
auth.close();
```
## One behaviour change from the extracted copies
The dialog now catches `on Object`, not `on Exception`.
An `Error` is not an `Exception` in Dart, so `on Exception` let `ArgumentError`
(notably from URI parsing) escape `_poll()` — leaving the dialog spinning on
"Waiting for authorization…" forever with nothing shown to the user. This is
the same trap that silently killed the Firebase sync tick across these apps in
2026-08. Fixed once here rather than four times. `test/device_code_dialog_test.dart`
pins it with a mock that throws an `Error` rather than an `Exception`.
## Commands
```bash
flutter pub get
flutter test # 20 tests
flutter test --coverage # 100% of lines
flutter analyze # very_good_analysis, clean
```

View File

@ -0,0 +1,38 @@
include: package:very_good_analysis/analysis_options.yaml
analyzer:
language:
strict-casts: true
strict-inference: true
strict-raw-types: true
errors:
# Promote key lints to errors so they fail CI, not just warn.
missing_required_param: error
unnecessary_null_comparison: error
dead_code: error
invalid_annotation_target: error
exclude:
- build/**
- "**/*.g.dart"
- "**/*.freezed.dart"
- "test/**"
- android/**
- ios/**
- web/**
- windows/**
- macos/**
- linux/**
linter:
rules:
# very_good_analysis enables most rules; add extras it doesn't include.
always_use_package_imports: true
avoid_print: true
avoid_relative_lib_imports: true
cancel_subscriptions: true
close_sinks: true
comment_references: false # conflicts with typical Flutter API docs style
directives_ordering: true
lines_longer_than_80_chars: true
public_member_api_docs: true
unawaited_futures: true

View File

@ -0,0 +1,22 @@
/// GitHub OAuth **Device Flow** for kuhy's Flutter apps: the auth client and
/// the modal that drives it.
///
/// Four apps (todo, home_inventory, diet-guard, wake-alarm) each carried a
/// copy of both halves — the todo and home_inventory clients were
/// byte-identical, as were the diet-guard and wake-alarm dialogs. This
/// package is that shared copy.
///
/// Device flow needs only a public `client_id` (no client secret), which is
/// what makes it safe for a distributed app; the resulting access token is
/// then used exactly like a PAT.
///
/// Deliberately **not** part of `sync_settings_ui`: that package is
/// Firebase-only by an explicit 2026-08-12 decision, and folding a GitHub
/// flow into it would reverse that. The GitHub mirror in these apps is
/// legacy-but-load-bearing code, so it gets its own home rather than being
/// merged into the Firebase path or deleted.
library;
export 'src/device_auth.dart'
show DeviceAuthException, DeviceCodeResponse, GitHubDeviceAuth;
export 'src/device_code_dialog.dart' show DeviceCodeDialog;

View File

@ -0,0 +1,154 @@
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
/// First-stage response of the GitHub OAuth Device Flow: the code the user
/// types on github.com and the URL to type it into.
class DeviceCodeResponse {
/// Creates a [DeviceCodeResponse] from its stored fields.
const DeviceCodeResponse({
required this.deviceCode,
required this.userCode,
required this.verificationUri,
required this.interval,
required this.expiresIn,
});
/// Builds a [DeviceCodeResponse] from GitHub's device-code 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] (github.com/login/device).
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] from GitHub's [code] and [message].
DeviceAuthException(this.code, this.message);
/// GitHub error code, e.g. `access_denied`, `expired_token`.
final String code;
/// Human-readable description of [code].
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 [GitHubClient].
///
/// References:
/// - POST https://github.com/login/device/code
/// - POST https://github.com/login/oauth/access_token
class GitHubDeviceAuth {
/// Creates a [GitHubDeviceAuth] for the given OAuth App [clientId].
GitHubDeviceAuth({
required this.clientId,
this.scope = 'repo',
http.Client? httpClient,
Future<void> Function(Duration)? delay,
}) : _http = httpClient ?? http.Client(),
// Indirection so tests can skip real waiting between polls.
_delay = delay ?? Future<void>.delayed;
/// The GitHub OAuth App's public client id.
final String clientId;
/// OAuth scope requested. `repo` is required for private-repo contents.
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));
final res = await _http.post(
Uri.parse(_tokenUrl),
headers: const {'Accept': 'application/json'},
body: {
'client_id': clientId,
'device_code': device.deviceCode,
'grant_type': _grantType,
},
);
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; // User has not finished authorizing yet.
case 'slow_down':
// GitHub asks us to back off; obey its new interval.
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. Call once when done polling.
void close() => _http.close();
}

View File

@ -0,0 +1,112 @@
/// The GitHub device-flow dialog: shows the user code, opens the page, and
/// polls until the grant is approved.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:github_device_auth/src/device_auth.dart';
import 'package:url_launcher/url_launcher.dart';
/// Dialog shown during the device flow: displays the user code, opens the
/// verification page, and polls until authorized — popping the token (or
/// null if cancelled / failed).
class DeviceCodeDialog extends StatefulWidget {
/// Creates the dialog for a pending device-flow grant.
const DeviceCodeDialog({
required this.device,
required this.auth,
super.key,
});
/// The pending grant: user code, verification URL, poll interval.
final DeviceCodeResponse device;
/// Auth client used to poll for the approved token.
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 Object`, not `on Exception`: an `Error` (notably `ArgumentError`
// from URI parsing) is not an `Exception`, so `on Exception` lets it
// escape and the dialog spins on "Waiting for authorization…" forever
// with nothing shown to the user. The same trap silently killed the
// Firebase sync tick in these apps -- see the 2026-08-11 fix.
on Object 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

@ -0,0 +1,306 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9"
url: "https://pub.dev"
source: hosted
version: "1.19.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.dev"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.dev"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.dev"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.dev"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.dev"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47
url: "https://pub.dev"
source: hosted
version: "2.4.2"
very_good_analysis:
dependency: "direct dev"
description:
name: very_good_analysis
sha256: "481af67ab5877af20325251dc215a4ebac7666a1c8cf09198ffd457bc612b33d"
url: "https://pub.dev"
source: hosted
version: "10.3.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.dev"
source: hosted
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sdks:
dart: ">=3.12.2 <4.0.0"
flutter: ">=3.44.0"

View File

@ -0,0 +1,23 @@
name: github_device_auth
description: >-
GitHub OAuth Device Flow for kuhy's Flutter apps: the auth client and the
modal that drives it. Extracted from four apps that each carried a copy,
two of them byte-identical.
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.12.2
dependencies:
flutter:
sdk: flutter
http: ^1.2.0
url_launcher: ^6.3.0
dev_dependencies:
flutter_test:
sdk: flutter
very_good_analysis: ^10.2.0
flutter:

View File

@ -0,0 +1,63 @@
#!/bin/bash
# ============================================================================
# Fail if lcov line coverage drops below a threshold.
#
# No Dart package in this repo has coverage enforced in CI yet -- crdt_sync_dart
# sits at 100% by discipline, not a gate. This script is that gate for
# sync_settings_ui, which ships as a 100%-covered shared UI package by
# requirement, not by convention.
#
# Usage: check_coverage.sh <lcov.info> <min_percent>
# ============================================================================
set -euo pipefail
SCRIPT_NAME="$(basename "$0")"
readonly SCRIPT_NAME
usage() {
echo "Usage: $SCRIPT_NAME <lcov.info> <min_percent>"
echo " Fails when line coverage in lcov.info is below min_percent."
exit 0
}
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
usage
fi
if [[ $# -ne 2 ]]; then
usage
fi
lcov_file="$1"
min_percent="$2"
if [[ ! -f "$lcov_file" ]]; then
echo "Error: $lcov_file not found. Run 'flutter test --coverage' first." >&2
exit 1
fi
# Sum LH (lines hit) and LF (lines found) across every SF (source file)
# block rather than trusting a single top-level total -- lcov.info has no
# such total line, only per-file LH/LF pairs.
read -r hit found <<< "$(awk '
/^LH:/ { hit += substr($0, 4) }
/^LF:/ { found += substr($0, 4) }
END { print hit, found }
' "$lcov_file")"
if [[ "$found" -eq 0 ]]; then
echo "Error: $lcov_file reports zero coverable lines." >&2
exit 1
fi
percent=$(awk -v h="$hit" -v f="$found" 'BEGIN { printf "%.2f", (h / f) * 100 }')
meets_threshold=$(awk -v p="$percent" -v m="$min_percent" 'BEGIN { print (p + 0 >= m + 0) ? 1 : 0 }')
echo "Line coverage: $hit/$found ($percent%), threshold: $min_percent%"
if [[ "$meets_threshold" -ne 1 ]]; then
echo "Error: coverage $percent% is below the required $min_percent%." >&2
exit 1
fi

View File

@ -0,0 +1,231 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:github_device_auth/github_device_auth.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
/// Never actually waits: the poll loop's pacing is injected so a test that
/// exercises `slow_down` does not take 15 real seconds.
Future<void> noDelay(Duration _) async {}
GitHubDeviceAuth authWith(
MockClient client, {
String scope = 'repo',
}) => GitHubDeviceAuth(
clientId: 'cid',
scope: scope,
httpClient: client,
delay: noDelay,
);
void main() {
group('DeviceCodeResponse.fromJson', () {
test('reads every field', () {
final r = DeviceCodeResponse.fromJson(const {
'device_code': 'dc',
'user_code': 'UC-1',
'verification_uri': 'https://github.com/login/device',
'interval': 7,
'expires_in': 100,
});
expect(r.deviceCode, 'dc');
expect(r.userCode, 'UC-1');
expect(r.verificationUri, 'https://github.com/login/device');
expect(r.interval, 7);
expect(r.expiresIn, 100);
});
test('defaults interval and expiry when GitHub omits them', () {
final r = DeviceCodeResponse.fromJson(const {
'device_code': 'dc',
'user_code': 'UC',
'verification_uri': 'u',
});
expect(r.interval, 5);
expect(r.expiresIn, 900);
});
});
test('DeviceAuthException stringifies code and message', () {
expect(
DeviceAuthException('access_denied', 'nope').toString(),
'DeviceAuthException(access_denied): nope',
);
});
group('requestDeviceCode', () {
test('posts the client id and scope, and parses the response', () async {
late http.Request seen;
final auth = authWith(
MockClient((req) async {
seen = req;
return http.Response(
jsonEncode({
'device_code': 'dc',
'user_code': 'UC',
'verification_uri': 'u',
}),
200,
);
}),
scope: 'repo,gist',
);
final device = await auth.requestDeviceCode();
expect(seen.url.toString(), 'https://github.com/login/device/code');
expect(seen.bodyFields['client_id'], 'cid');
expect(seen.bodyFields['scope'], 'repo,gist');
expect(device.userCode, 'UC');
});
test('throws with the status code on a non-200', () async {
final auth = authWith(MockClient((_) async => http.Response('bad', 503)));
await expectLater(
auth.requestDeviceCode(),
throwsA(
isA<DeviceAuthException>()
.having((e) => e.code, 'code', 'http_503')
.having((e) => e.message, 'message', 'bad'),
),
);
});
});
group('pollForToken', () {
const device = DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
expiresIn: 60,
);
test('returns the token once the user authorizes', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
// First poll pending, second succeeds -- the normal path.
return http.Response(
calls == 1
? jsonEncode({'error': 'authorization_pending'})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
expect(calls, 2);
});
test('obeys slow_down by taking GitHub s new interval', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
return http.Response(
calls == 1
? jsonEncode({'error': 'slow_down', 'interval': 30})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
});
test('slow_down without an interval still backs off', () async {
var calls = 0;
final auth = authWith(
MockClient((_) async {
calls += 1;
return http.Response(
calls == 1
? jsonEncode({'error': 'slow_down'})
: jsonEncode({'access_token': 'tok'}),
200,
);
}),
);
expect(await auth.pollForToken(device), 'tok');
});
test('throws on a terminal error', () async {
final auth = authWith(
MockClient(
(_) async => http.Response(
jsonEncode({
'error': 'access_denied',
'error_description': 'user said no',
}),
200,
),
),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>()
.having((e) => e.code, 'code', 'access_denied')
.having((e) => e.message, 'message', 'user said no'),
),
);
});
test('falls back to the code when no description is given', () async {
final auth = authWith(
MockClient(
(_) async => http.Response(jsonEncode({'error': 'expired'}), 200),
),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>().having((e) => e.message, 'msg', 'expired'),
),
);
});
test('throws on a response with neither a token nor an error', () async {
final auth = authWith(
MockClient((_) async => http.Response(jsonEncode({'x': 1}), 200)),
);
await expectLater(
auth.pollForToken(device),
throwsA(
isA<DeviceAuthException>().having((e) => e.code, 'code', 'unknown'),
),
);
});
test('gives up once the device code has expired', () async {
const expired = DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'UC',
verificationUri: 'u',
interval: 1,
// Already in the past, so the loop body never runs.
expiresIn: -1,
);
final auth = authWith(MockClient((_) async => http.Response('{}', 200)));
await expectLater(
auth.pollForToken(expired),
throwsA(
isA<DeviceAuthException>()
.having((e) => e.code, 'code', 'expired_token'),
),
);
});
});
test('close() is safe to call', () {
authWith(MockClient((_) async => http.Response('{}', 200))).close();
});
test('constructs with a real http client and default scope', () {
final auth = GitHubDeviceAuth(clientId: 'cid')..close();
expect(auth.scope, 'repo');
});
}

View File

@ -0,0 +1,169 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:github_device_auth/github_device_auth.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
const _device = DeviceCodeResponse(
deviceCode: 'dc',
userCode: 'ABCD-1234',
verificationUri: 'https://github.com/login/device',
interval: 1,
expiresIn: 60,
);
/// Completes immediately -- for tests whose mock resolves on the first poll.
Future<void> _noDelay(Duration _) async {}
/// Never completes, so the poll loop suspends on its first delay.
///
/// The pending-state tests need the dialog to sit in "waiting" without
/// actually looping: with an instantly-completing delay and a mock that
/// always answers `authorization_pending`, `pollForToken` spins as fast as
/// the event loop allows and starves the test binding.
Future<void> _neverDelay(Duration _) => Completer<void>().future;
GitHubDeviceAuth _auth(
MockClient client, {
Future<void> Function(Duration) delay = _noDelay,
}) => GitHubDeviceAuth(clientId: 'cid', httpClient: client, delay: delay);
/// An auth client that never gets past its first poll delay.
GitHubDeviceAuth _pendingAuth() => _auth(
MockClient(
(_) async => http.Response(jsonEncode({'error': 'authorization_pending'}), 200),
),
delay: _neverDelay,
);
/// Opens the dialog and returns a getter for whatever it eventually pops.
///
/// The result is read through a closure rather than returned directly
/// because the dialog may still be polling when this returns -- a test that
/// cares about the popped value pumps first, then reads.
Future<Object? Function()> _showDialog(
WidgetTester tester,
GitHubDeviceAuth auth,
) async {
Object? popped;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (context) => ElevatedButton(
onPressed: () async {
popped = await showDialog<String>(
context: context,
builder: (_) => DeviceCodeDialog(device: _device, auth: auth),
);
},
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pump();
return () => popped;
}
void main() {
testWidgets('shows the user code and a waiting state', (tester) async {
await _showDialog(tester, _pendingAuth());
expect(find.text('Authorize on GitHub'), findsOneWidget);
expect(find.text('ABCD-1234'), findsOneWidget);
expect(find.text('Waiting for authorization…'), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('pops the token once authorized', (tester) async {
final auth = _auth(
MockClient(
(_) async => http.Response(jsonEncode({'access_token': 'tok'}), 200),
),
);
final popped = await _showDialog(tester, auth);
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(popped(), 'tok');
});
testWidgets('shows the error instead of spinning forever', (tester) async {
final auth = _auth(
MockClient(
(_) async => http.Response(
jsonEncode({
'error': 'access_denied',
'error_description': 'user declined',
}),
200,
),
),
);
await _showDialog(tester, auth);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.textContaining('access_denied'), findsOneWidget);
});
testWidgets('surfaces an Error, not just an Exception', (tester) async {
// The regression this dialog's `on Object catch` exists for: an
// `ArgumentError` is an Error, not an Exception, so `on Exception` would
// let it escape and leave the dialog spinning with nothing shown.
final auth = _auth(
MockClient((_) async => throw ArgumentError('No host specified in URI')),
);
await _showDialog(tester, auth);
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.textContaining('No host specified'), findsOneWidget);
});
testWidgets('cancel pops without a token', (tester) async {
final popped = await _showDialog(tester, _pendingAuth());
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
expect(popped(), isNull);
});
testWidgets('the action button copies the code to the clipboard', (
tester,
) async {
final auth = _pendingAuth();
String? copied;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
copied =
((call.arguments as Map<Object?, Object?>)['text'] as String?);
}
return null;
},
);
// url_launcher would otherwise hit a missing plugin on the test binding.
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/url_launcher'),
(_) async => true,
);
await _showDialog(tester, auth);
await tester.tap(find.text('Open GitHub & copy code'));
await tester.pump();
expect(copied, 'ABCD-1234');
});
}