Skip to content
Open
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
2 changes: 1 addition & 1 deletion android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
2 changes: 1 addition & 1 deletion android/settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pluginManagement {

plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false
id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}

Expand Down
139 changes: 139 additions & 0 deletions docs/solutions/best-practices/flutter-launch-update-checking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
---
title: Launch-Time Update Checking Against GitHub Releases
date: 2026-08-03
category: docs/solutions/best-practices/
module: Flutter app lifecycle and settings
problem_type: best_practice
component: development_workflow
severity: low
applies_when:
- "Adding or modifying update-check or version-check behavior"
- "Adding any network call that runs during app startup"
- "Comparing semantic versions across tag formats (v-prefix, pre-release suffixes)"
related_components:
- lib/core/utils/update_checker.dart
- lib/core/utils/dialog_utils.dart
- lib/controllers/preferences_controller.dart
- lib/screens/settings/sections/general.dart
- lib/app.dart
tags: [update-check, version-compare, github-releases, startup, preferences, flutter]
---

# Launch-Time Update Checking Against GitHub Releases

## Context

Telepathy needed a way to notify desktop users when a newer release exists
(GitHub issue #35). Update checking is a startup network call, which makes it
easy to get wrong in familiar ways: blocking first paint on an HTTP request,
crashing the app when the network is down or GitHub rate-limits, and
misfiring on version strings that carry `v` prefixes or pre-release suffixes.

## Guidance

The implementation has four parts, each with a deliberate shape:

**1. A result type, never a thrown exception** (`lib/core/utils/update_checker.dart`).
`UpdateChecker.check()` returns an `UpdateCheckResult` that is one of
`upToDate`, `updateAvailable`, or `failed`. Every failure path — non-200
status, malformed JSON, missing `tag_name`/`html_url`, invalid release URL,
timeout, transport error — is funnelled into `UpdateCheckResult.failed` and
logged via `DebugConsole.warn`. The UI can then stay simple: show the dialog
only when `availableUpdate != null`, and the launch path ignores failures
entirely.

**2. Injectable seams for the network and the installed version.**
`UpdateChecker` takes an optional `http.Client` and an optional
`installedVersion` callback (defaulting to `package_info_plus`). Tests inject
a `MockClient` and a fixed version string, so the full check logic —
parsing, comparison, failure mapping — is covered without real network or
platform channels. This is the only mock-worthy seam here: the GitHub API is
an external service.

**3. Tolerant three-segment version comparison.**
`UpdateChecker.isNewerVersion` strips a leading `v`/`V`, pads missing
segments with zero, and takes the numeric prefix of each segment (so
`v2.8.2-beta.1` compares as `2.8.2`). It compares major, minor, patch in
order and returns true only when the release is strictly newer. This avoids
pulling in a full semver package for a comparison the project's own tag
format controls.

**4. A preference-gated, post-frame launch hook.**
In `lib/app.dart`, `_TelepathyAppState.initState` schedules the check with
`WidgetsBinding.instance.addPostFrameCallback`, so first paint never waits on
HTTP. The check reads `PreferencesController.automaticUpdateChecks`
(persisted via the options store, default `true`) and shows
`showUpdateAvailableDialog` through the global `navigatorKey`, since the
post-frame `context` is not reliable for navigation. The dialog's "View
Release" action opens the release page with `url_launcher` in
`LaunchMode.externalApplication` mode. The same checker backs a manual
"check now" button and an opt-out toggle in Settings → General
(`lib/screens/settings/sections/general.dart`).

## Why This Matters

Startup is the worst place for unguarded I/O: a hung or slow request delays
first paint for every user on every launch, and an unhandled exception in
`initState` can break the whole app for users on flaky networks. Routing
every failure into a result type keeps the launch path crash-proof by
construction rather than by try/catch discipline at each call site. The
10-second timeout on the request bounds the worst case.

The manual settings button reuses the same `UpdateChecker`, so the opt-out
preference, the dialog, and the comparison logic have exactly one
implementation — a fix to any of them applies everywhere.

## When to Apply

- Any network call scheduled from `initState`: use a post-frame callback, a
persisted opt-out preference, and a result type instead of exceptions.
- Any version comparison in this repo: reuse `UpdateChecker.isNewerVersion`
rather than writing a new parser — GitHub tags carry a `v` prefix while
`package_info_plus` reports the bare version.
- Any external-URL action: prefer `url_launcher` with
`LaunchMode.externalApplication` and handle a `false` return (log, don't
throw).

## Examples

Launch hook in `lib/app.dart`:

```dart
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
});

Future<void> _checkForUpdates() async {
if (!context.read<PreferencesController>().automaticUpdateChecks) {
return;
}

final update = (await UpdateChecker().check()).availableUpdate;
final navigator = navigatorKey.currentState;
if (update != null && navigator?.mounted == true) {
await showUpdateAvailableDialog(navigator!.context, update);
}
}
```

Test seam in `test/core/utils/update_checker_test.dart`:

```dart
final checker = UpdateChecker(
client: MockClient((request) async {
return http.Response(
'{"tag_name":"v2.9.0","html_url":"https://github.com/chanderlud/telepathy/releases/tag/v2.9.0"}',
200,
);
}),
installedVersion: () async => '2.8.1',
);
```

## Related

- GitHub issue #35 ("Latest version/update check") — the originating request
- PR #71 (`feature/version-checking`) — the implementation, open as of this writing
- `docs/solutions/conventions/platform-launch-smoke-ci-2026-08-03.md` — the
launch-smoke CI convention; an update dialog shown on launch is startup
behavior the smoke tests exercise
17 changes: 16 additions & 1 deletion lib/app.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import 'package:flutter/material.dart' hide Overlay;
import 'package:provider/provider.dart';
import 'package:telepathy/core/theme/app_theme.dart';
import 'package:telepathy/core/utils/index.dart';
import 'package:telepathy/controllers/index.dart';
import 'package:telepathy/screens/home/home_page.dart';

import 'package:telepathy/core/rust/flutter.dart';
import 'package:telepathy/core/utils/io_shim.dart';
import 'package:window_manager/window_manager.dart';

final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
Expand All @@ -28,6 +28,21 @@ class _TelepathyAppState extends State<TelepathyApp> with WindowListener {
windowManager.addListener(this);
_initWindow();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
});
}

Future<void> _checkForUpdates() async {
if (!context.read<PreferencesController>().automaticUpdateChecks) {
return;
}

final update = (await UpdateChecker().check()).availableUpdate;
final navigator = navigatorKey.currentState;
if (update != null && navigator?.mounted == true) {
await showUpdateAvailableDialog(navigator!.context, update);
}
}

Future<void> _initWindow() async {
Expand Down
10 changes: 10 additions & 0 deletions lib/controllers/preferences_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@ class PreferencesController with ChangeNotifier {

late bool efficiencyMode;

late bool automaticUpdateChecks;

PreferencesController({required this.options});

Future<void> init() async {
playCustomRingtones = await options.getBool('playCustomRingtones') ?? true;
customRingtoneFile = await options.getString('customRingtoneFile');
efficiencyMode = await options.getBool('efficiencyMode') ?? false;
automaticUpdateChecks =
await options.getBool('automaticUpdateChecks') ?? true;
notifyListeners();
}

Expand All @@ -44,4 +48,10 @@ class PreferencesController with ChangeNotifier {
await options.setBool('efficiencyMode', enabled);
notifyListeners();
}

Future<void> updateAutomaticUpdateChecks(bool enabled) async {
automaticUpdateChecks = enabled;
await options.setBool('automaticUpdateChecks', enabled);
notifyListeners();
}
}
51 changes: 51 additions & 0 deletions lib/core/utils/dialog_utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import 'dart:async';

import 'package:flutter/material.dart';
import 'package:telepathy/core/rust/types.dart';
import 'package:telepathy/core/utils/console.dart';
import 'package:telepathy/core/utils/update_checker.dart';
import 'package:telepathy/widgets/common/index.dart';
import 'package:url_launcher/url_launcher.dart';

/// Shows an error modal.
void showErrorDialog(BuildContext context, String title, String errorMessage) {
Expand All @@ -28,6 +31,54 @@ void showErrorDialog(BuildContext context, String title, String errorMessage) {
);
}

Future<void> showUpdateAvailableDialog(
BuildContext context,
AvailableUpdate update,
) {
return showDialog<void>(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Update Available'),
content: Text(
'Telepathy ${update.version} is available. '
'Open the release page to download it.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Later'),
),
TextButton(
onPressed: () async {
try {
final launched = await launchUrl(
update.releaseUrl,
mode: LaunchMode.externalApplication,
);
if (launched) {
return;
}
DebugConsole.warn(
'Could not open release URL: ${update.releaseUrl}',
);
} catch (error) {
DebugConsole.warn(
'Could not open release URL ${update.releaseUrl}: $error',
);
}
},
child: const Text('View Release'),
),
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
);
},
);
}

/// Prompts the user to accept an incoming call.
Future<bool> acceptCallPrompt(
BuildContext context,
Expand Down
1 change: 1 addition & 0 deletions lib/core/utils/index.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export 'format_utils.dart';
export 'io_shim.dart';
export 'layout_context.dart';
export 'sound_effects.dart';
export 'update_checker.dart';
Loading
Loading