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
526 changes: 526 additions & 0 deletions lib/components/feedback/app_update_pill.dart

Large diffs are not rendered by default.

317 changes: 307 additions & 10 deletions lib/components/feedback/lib_update_tips.dart

Large diffs are not rendered by default.

203 changes: 203 additions & 0 deletions lib/core/providers/app_update_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import 'dart:async';
import 'dart:convert';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;

import '../utils/sdk_version.dart';
import 'app_version_provider.dart';

/// Latest published release of the DevConnect Manage Tool desktop app
/// itself (not the SDKs the user-facing tip covers). Fetched on
/// construction from GitHub Releases, then auto-refreshed every
/// [_ttl].
///
/// The currently-running version is sourced from [appVersionProvider]
/// (which reads `PackageInfo.fromPlatform()` — the binary's own
/// Info.plist, set from `pubspec.yaml` at build time). No Dart-side
/// version constants to drift.
class AppRelease {
final String version;
final String htmlUrl;
final String? notes;

const AppRelease({
required this.version,
required this.htmlUrl,
this.notes,
});
}

class AppReleaseState {
/// Version of the running app, sourced from [appVersionProvider].
/// Null until the platform channel has responded.
final String? currentVersion;

final AppRelease? release;
final DateTime? fetchedAt;
final String? error;

const AppReleaseState({
this.currentVersion,
this.release,
this.fetchedAt,
this.error,
});

static const empty = AppReleaseState();

/// True when we have both the running version and a published
/// release, and the running version is older.
bool get hasUpdate {
final r = release;
final cur = currentVersion;
if (r == null || cur == null) return false;
return compareSdkVersions(cur, r.version) < 0;
}
}

/// Unoauthenticated GitHub REST endpoint for the most recent release.
/// 60 req/hr limit per IP — desktop app caches, so a single cold
/// start is one call.
const _releasesUrl =
'https://api.github.com/repos/ridelinktechs/devconnect-manage-kit/releases/latest';

final appUpdateProvider =
StateNotifierProvider<AppUpdateNotifier, AppReleaseState>((ref) {
final notifier = AppUpdateNotifier(ref);
ref.onDispose(notifier._onDispose);
return notifier;
});

class AppUpdateNotifier extends StateNotifier<AppReleaseState> {
static const _ttl = Duration(minutes: 30);
static const _timeout = Duration(seconds: 5);

final Ref _ref;
Timer? _refresh;

AppUpdateNotifier(this._ref) : super(AppReleaseState.empty) {
_refresh = Timer.periodic(_ttl, (_) => _refreshNow());
// ignore: discarded_futures
_bootstrap();
}

/// Manually re-fetch. UI surfaces this as a "Retry" affordance.
Future<void> refresh() => _refreshNow();

/// One-shot init: load the running version, then kick off the
/// GitHub fetch. Order matters — `hasUpdate` needs both pieces.
Future<void> _bootstrap() async {
// PackageInfo.fromPlatform() can throw on rare platform-channel
// failures. Without this try/catch the exception bubbles out of
// the StateNotifier constructor (which ignores it) AND leaves the
// GitHub fetch never started.
String? cur;
try {
cur = await _ref.read(appVersionProvider.future);
} catch (e) {
if (!mounted) return;
state = AppReleaseState(error: 'package_info: $e');
return;
}
if (!mounted) return;
state = AppReleaseState(currentVersion: cur);
await _refreshNow();
}
Comment on lines +90 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In _bootstrap(), await _ref.read(appVersionProvider.future) is called outside of any try-catch block. If appVersionProvider throws an exception (for example, if PackageInfo.fromPlatform() fails on an unsupported platform or during unit tests), the exception will be unhandled because _bootstrap() is called asynchronously in the constructor without error handling.

Wrapping this call in a try-catch block ensures the provider initializes gracefully even if the platform channel fails.

  Future<void> _bootstrap() async {
    try {
      final cur = await _ref.read(appVersionProvider.future);
      if (!mounted) return;
      state = AppReleaseState(currentVersion: cur);
      await _refreshNow();
    } catch (e) {
      if (!mounted) return;
      state = AppReleaseState(
        error: 'Failed to load app version: $e',
        fetchedAt: DateTime.now(),
      );
    }
  }


Future<void> _refreshNow() async {
// Make sure we have the running version before checking
// `hasUpdate`. Subsequent fetches after the first will already
// have it cached in state.
String? cur;
try {
cur = state.currentVersion ?? await _ref.read(appVersionProvider.future);
} catch (e) {
if (!mounted) return;
state = AppReleaseState(
release: state.release,
currentVersion: state.currentVersion,
fetchedAt: DateTime.now(),
error: 'package_info: $e',
);
return;
}
if (!mounted) return;

try {
final resp = await http
Comment on lines +108 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In _refreshNow(), the call to _ref.read(appVersionProvider.future) is executed outside of the try-catch block. If it throws an error, it will result in an unhandled exception. Wrapping it in a try-catch block ensures any platform channel errors are caught and handled gracefully.

Suggested change
Future<void> _refreshNow() async {
// Make sure we have the running version before checking
// `hasUpdate`. Subsequent fetches after the first will already
// have it cached in state.
final cur = state.currentVersion ??
await _ref.read(appVersionProvider.future);
if (!mounted) return;
try {
final resp = await http
Future<void> _refreshNow() async {
String? cur;
try {
cur = state.currentVersion ??
await _ref.read(appVersionProvider.future);
} catch (e) {
if (!mounted) return;
state = AppReleaseState(
error: 'Failed to load app version: $e',
fetchedAt: DateTime.now(),
);
return;
}
if (!mounted) return;
try {
final resp = await http

.get(
Uri.parse(_releasesUrl),
headers: const {
'Accept': 'application/vnd.github+json',
},
)
.timeout(_timeout);
if (!mounted) return;
if (resp.statusCode != 200) {
// Preserve the last-known release so a transient 5xx / 403
// doesn't suddenly flip the UI from "Update available" back
// to a stale "loading" state.
state = AppReleaseState(
currentVersion: cur,
release: state.release,
fetchedAt: DateTime.now(),
error: 'HTTP ${resp.statusCode}',
);
return;
}
final json = jsonDecode(resp.body);
if (json is! Map<String, dynamic>) {
state = AppReleaseState(
currentVersion: cur,
release: state.release,
fetchedAt: DateTime.now(),
error: 'unexpected payload',
);
return;
}
final tag = json['tag_name'];
final url = json['html_url'];
if (tag is! String || url is! String) {
state = AppReleaseState(
currentVersion: cur,
release: state.release,
fetchedAt: DateTime.now(),
error: 'missing tag_name / html_url',
);
return;
}
// Strip leading "v" if the tag uses it (e.g. "v1.0.4" -> "1.0.4").
final version = tag.startsWith('v') ? tag.substring(1) : tag;
state = AppReleaseState(
currentVersion: cur,
release: AppRelease(
version: version,
htmlUrl: url,
notes: json['body'] is String ? json['body'] as String : null,
),
fetchedAt: DateTime.now(),
);
} on TimeoutException {
if (!mounted) return;
state = AppReleaseState(
currentVersion: cur,
release: state.release,
fetchedAt: DateTime.now(),
error: 'timeout',
);
} catch (e) {
if (!mounted) return;
state = AppReleaseState(
currentVersion: cur,
release: state.release,
fetchedAt: DateTime.now(),
error: e.toString(),
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

void _onDispose() {
_refresh?.cancel();
}
}
18 changes: 18 additions & 0 deletions lib/core/providers/app_version_provider.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';

/// Single source of truth for the desktop app's own version.
///
/// Reads from `PackageInfo.fromPlatform()` — that returns the version
/// baked into the compiled binary's Info.plist (macOS), which Flutter
/// sets from `pubspec.yaml` during `flutter build`. So bumping
/// `pubspec.yaml` is the only thing required to "ship" a new version;
/// no Dart constants to update.
///
/// Returns `AsyncValue<String>` so callers can render a loading state
/// while the platform channel roundtrip is in flight. Resolved value
/// is cached by Riverpod for the lifetime of the provider scope.
final appVersionProvider = FutureProvider<String>((_) async {
final info = await PackageInfo.fromPlatform();
return info.version;
});
Loading
Loading