From ad3b3171157d91d1db7f98bd9616238c13d7ffbf Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 1 Mar 2026 14:08:17 -0500 Subject: [PATCH] Add self-hosted update system via Forgejo releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app now checks for new APK releases on a Forgejo instance and can download and install them without leaving the app. Settings: - New "Update repository URL" field (e.g. https://git.example.com/user/fabled_app) - "App version" tile with a manual "Check" button and live status display - Download progress bar and "Install" button appear when update is found Startup: - Shell runs a silent background check on first load when a repo URL is set - A dialog appears automatically if a newer release is found Provider (update_provider.dart): - UpdateNotifier: idle → checking → available → downloading → available - Hits Forgejo /api/v1/repos/{owner}/{repo}/releases/latest - Parses semver tag, finds .apk asset, downloads with progress via Dio - Opens the APK with open_file to trigger the system package installer Android: - REQUEST_INSTALL_PACKAGES permission - FileProvider configured for open_file (shares APK URI with installer) - res/xml/file_paths.xml covers external-files and cache directories Co-Authored-By: Claude Sonnet 4.6 --- android/app/src/main/AndroidManifest.xml | 14 ++ android/app/src/main/res/xml/file_paths.xml | 7 + lib/app.dart | 85 +++++++++- lib/providers/settings_provider.dart | 18 ++ lib/providers/update_provider.dart | 156 ++++++++++++++++++ lib/screens/settings/settings_screen.dart | 148 +++++++++++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 96 +++++++++++ pubspec.yaml | 2 + 11 files changed, 533 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 lib/providers/update_provider.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 214ae2e..859f082 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + + + + + + + + + + + + diff --git a/lib/app.dart b/lib/app.dart index 473f386..4d7d702 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -9,6 +9,7 @@ import 'providers/auth_provider.dart'; import 'providers/capture_queue_provider.dart'; import 'providers/notes_provider.dart'; import 'providers/settings_provider.dart'; +import 'providers/update_provider.dart'; import 'providers/tasks_provider.dart'; import 'screens/auth/login_screen.dart'; import 'screens/chat/chat_screen.dart'; @@ -127,12 +128,29 @@ final routerProvider = Provider((ref) { ); }); -class _Shell extends ConsumerWidget { +class _Shell extends ConsumerStatefulWidget { final Widget child; const _Shell({required this.child}); + @override + ConsumerState<_Shell> createState() => _ShellState(); +} + +class _ShellState extends ConsumerState<_Shell> { static const _tabs = [Routes.notes, Routes.tasks, Routes.conversations]; + @override + void initState() { + super.initState(); + // Silent update check on first app load. + WidgetsBinding.instance.addPostFrameCallback((_) { + final repoUrl = ref.read(forgejoRepoUrlProvider); + if (repoUrl != null && repoUrl.isNotEmpty) { + ref.read(updateProvider.notifier).check(repoUrl); + } + }); + } + int _tabIndex(String location) { for (var i = 0; i < _tabs.length; i++) { if (location.startsWith(_tabs[i])) return i; @@ -140,10 +158,73 @@ class _Shell extends ConsumerWidget { return 0; } + void _showUpdateDialog(UpdateState update) { + showDialog( + context: context, + builder: (dialogContext) => Consumer( + builder: (context, ref, _) { + final state = ref.watch(updateProvider); + final isDownloading = state.status == UpdateStatus.downloading; + return AlertDialog( + title: const Text('Update available'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Version ${state.latestVersion} is ready to install.'), + if (state.currentVersion != null) + Text( + 'Installed: v${state.currentVersion}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (isDownloading) ...[ + const SizedBox(height: 16), + LinearProgressIndicator( + value: state.downloadProgress > 0 + ? state.downloadProgress + : null, + ), + const SizedBox(height: 4), + Text( + 'Downloading… ' + '${(state.downloadProgress * 100).toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Later'), + ), + if (!isDownloading) + FilledButton( + onPressed: () => ref + .read(updateProvider.notifier) + .downloadAndInstall(), + child: const Text('Download & Install'), + ), + ], + ); + }, + ), + ); + } + @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { + // Show update dialog once when a new version is detected. + ref.listen(updateProvider, (prev, next) { + if (next.status == UpdateStatus.available && + prev?.status != UpdateStatus.available) { + WidgetsBinding.instance + .addPostFrameCallback((_) => _showUpdateDialog(next)); + } + }); final location = GoRouterState.of(context).matchedLocation; final index = _tabIndex(location); + final child = widget.child; // Use NavigationRail whenever the screen is wide enough — covers both // phone landscape and tablets in either orientation (600 dp breakpoint). final isWide = MediaQuery.of(context).size.width >= 600; diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 1c0cf54..96395e4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart'; const _kServerUrl = 'server_url'; const _kThemeMode = 'theme_mode'; +const _kForgejoRepoUrl = 'forgejo_repo_url'; final sharedPreferencesProvider = Provider((ref) { throw UnimplementedError('Override in ProviderScope'); @@ -42,6 +43,23 @@ class ThemeModeNotifier extends StateNotifier { } } +final forgejoRepoUrlProvider = + StateNotifierProvider((ref) { + return ForgejoRepoUrlNotifier(ref.watch(sharedPreferencesProvider)); +}); + +class ForgejoRepoUrlNotifier extends StateNotifier { + final SharedPreferences _prefs; + ForgejoRepoUrlNotifier(this._prefs) + : super(_prefs.getString(_kForgejoRepoUrl)); + + Future setUrl(String url) async { + final clean = url.endsWith('/') ? url.substring(0, url.length - 1) : url; + await _prefs.setString(_kForgejoRepoUrl, clean); + state = clean; + } +} + final serverUrlProvider = StateNotifierProvider((ref) { final prefs = ref.watch(sharedPreferencesProvider); return ServerUrlNotifier(prefs); diff --git a/lib/providers/update_provider.dart b/lib/providers/update_provider.dart new file mode 100644 index 0000000..bc34ea8 --- /dev/null +++ b/lib/providers/update_provider.dart @@ -0,0 +1,156 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:open_file/open_file.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider/path_provider.dart'; + +enum UpdateStatus { idle, checking, available, downloading, upToDate, error } + +class UpdateState { + final UpdateStatus status; + final String? currentVersion; + final String? latestVersion; + final String? downloadUrl; + final double downloadProgress; + final String? errorMessage; + + const UpdateState({ + this.status = UpdateStatus.idle, + this.currentVersion, + this.latestVersion, + this.downloadUrl, + this.downloadProgress = 0.0, + this.errorMessage, + }); + + UpdateState copyWith({ + UpdateStatus? status, + String? currentVersion, + String? latestVersion, + String? downloadUrl, + double? downloadProgress, + String? errorMessage, + }) => + UpdateState( + status: status ?? this.status, + currentVersion: currentVersion ?? this.currentVersion, + latestVersion: latestVersion ?? this.latestVersion, + downloadUrl: downloadUrl ?? this.downloadUrl, + downloadProgress: downloadProgress ?? this.downloadProgress, + errorMessage: errorMessage ?? this.errorMessage, + ); +} + +class UpdateNotifier extends Notifier { + @override + UpdateState build() => const UpdateState(); + + /// [repoUrl] is the Forgejo repo page URL, e.g. + /// "https://git.example.com/user/fabled_app" + Future check(String repoUrl) async { + state = state.copyWith(status: UpdateStatus.checking); + try { + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = packageInfo.version; + + // Parse repo URL → Forgejo API endpoint + final uri = Uri.parse(repoUrl); + final parts = + uri.pathSegments.where((s) => s.isNotEmpty).toList(); + if (parts.length < 2) throw 'Invalid repository URL (need /owner/repo)'; + final apiUrl = + '${uri.scheme}://${uri.authority}/api/v1/repos/${parts[0]}/${parts[1]}/releases/latest'; + + final response = await Dio().get(apiUrl); + final tagName = (response.data['tag_name'] as String? ?? '').trim(); + final latestVersion = + tagName.startsWith('v') ? tagName.substring(1) : tagName; + + if (_isNewer(latestVersion, currentVersion)) { + final assets = + (response.data['assets'] as List? ?? []) + .cast>(); + final apk = assets.firstWhere( + (a) => (a['name'] as String? ?? '').endsWith('.apk'), + orElse: () => {}, + ); + if (apk.isNotEmpty) { + state = state.copyWith( + status: UpdateStatus.available, + currentVersion: currentVersion, + latestVersion: latestVersion, + downloadUrl: apk['browser_download_url'] as String?, + ); + return; + } + } + + state = state.copyWith( + status: UpdateStatus.upToDate, + currentVersion: currentVersion, + latestVersion: latestVersion, + ); + } catch (e) { + state = state.copyWith( + status: UpdateStatus.error, + errorMessage: e.toString(), + ); + } + } + + Future downloadAndInstall() async { + if (state.downloadUrl == null) return; + state = state.copyWith(status: UpdateStatus.downloading, downloadProgress: 0); + + try { + final dir = await getExternalStorageDirectory() ?? + await getTemporaryDirectory(); + final path = '${dir.path}/fabled_update.apk'; + + await Dio().download( + state.downloadUrl!, + path, + onReceiveProgress: (received, total) { + if (total > 0) { + state = state.copyWith(downloadProgress: received / total); + } + }, + ); + + await OpenFile.open( + path, + type: 'application/vnd.android.package-archive', + ); + + // Return to available so the user can retry install if they dismissed it. + state = state.copyWith( + status: UpdateStatus.available, + downloadProgress: 1.0, + ); + } catch (e) { + state = state.copyWith( + status: UpdateStatus.error, + errorMessage: e.toString(), + ); + } + } + + void dismiss() => state = const UpdateState(); + + bool _isNewer(String latest, String current) { + try { + final l = latest.split('.').map(int.parse).toList(); + final c = current.split('.').map(int.parse).toList(); + for (var i = 0; i < l.length && i < c.length; i++) { + if (l[i] > c[i]) return true; + if (l[i] < c[i]) return false; + } + return l.length > c.length; + } catch (_) { + return false; + } + } +} + +final updateProvider = + NotifierProvider(UpdateNotifier.new); diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 3ea70dc..652886f 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../core/constants.dart'; import '../../providers/auth_provider.dart'; import '../../providers/settings_provider.dart'; +import '../../providers/update_provider.dart'; class SettingsScreen extends ConsumerWidget { const SettingsScreen({super.key}); @@ -13,6 +14,8 @@ class SettingsScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final serverUrl = ref.watch(serverUrlProvider); final themeMode = ref.watch(themeModeProvider); + final repoUrl = ref.watch(forgejoRepoUrlProvider); + final update = ref.watch(updateProvider); return Scaffold( appBar: AppBar(title: const Text('Settings')), @@ -52,6 +55,59 @@ class SettingsScreen extends ConsumerWidget { ), ), const Divider(), + + // ── Updates ────────────────────────────────────────────────────── + ListTile( + leading: const Icon(Icons.update), + title: const Text('Update repository'), + subtitle: Text(repoUrl?.isNotEmpty == true + ? repoUrl! + : 'Not configured — tap to set'), + onTap: () => _editRepoUrl(context, ref, repoUrl), + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('App version'), + subtitle: _versionSubtitle(update), + trailing: update.status == UpdateStatus.checking + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : TextButton( + onPressed: () { + final url = ref.read(forgejoRepoUrlProvider); + if (url == null || url.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Set a repository URL first.'), + ), + ); + return; + } + ref.read(updateProvider.notifier).check(url); + }, + child: const Text('Check'), + ), + ), + if (update.status == UpdateStatus.available || + update.status == UpdateStatus.downloading) + _UpdateTile(update: update), + if (update.status == UpdateStatus.error) + ListTile( + leading: + const Icon(Icons.error_outline, color: Colors.red), + title: const Text('Update check failed'), + subtitle: Text( + update.errorMessage ?? 'Unknown error', + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + + const Divider(), ListTile( title: const Text('Sign Out'), leading: const Icon(Icons.logout), @@ -64,4 +120,96 @@ class SettingsScreen extends ConsumerWidget { ), ); } + + Widget _versionSubtitle(UpdateState update) { + final current = update.currentVersion; + final latest = update.latestVersion; + + if (current == null && latest == null) { + return const Text('Tap "Check" to look for updates'); + } + if (update.status == UpdateStatus.upToDate) { + return Text('v$current — up to date'); + } + if (update.status == UpdateStatus.available || + update.status == UpdateStatus.downloading) { + return Text('v$current installed'); + } + return Text(current != null ? 'v$current' : ''); + } + + Future _editRepoUrl( + BuildContext context, WidgetRef ref, String? current) async { + String draft = current ?? ''; + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Update repository URL'), + content: TextFormField( + initialValue: draft, + onChanged: (v) => draft = v, + decoration: const InputDecoration( + hintText: 'https://git.example.com/user/fabled_app', + border: OutlineInputBorder(), + ), + autocorrect: false, + keyboardType: TextInputType.url, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + ref + .read(forgejoRepoUrlProvider.notifier) + .setUrl(draft.trim()); + Navigator.pop(dialogContext); + }, + child: const Text('Save'), + ), + ], + ), + ); + } +} + +class _UpdateTile extends ConsumerWidget { + final UpdateState update; + const _UpdateTile({required this.update}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDownloading = update.status == UpdateStatus.downloading; + + return ListTile( + leading: const Icon(Icons.system_update, color: Colors.green), + title: Text('v${update.latestVersion} available'), + subtitle: isDownloading + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 4), + LinearProgressIndicator( + value: update.downloadProgress > 0 + ? update.downloadProgress + : null, + ), + const SizedBox(height: 2), + Text( + '${(update.downloadProgress * 100).toStringAsFixed(0)}%'), + ], + ) + : const Text('Tap to download and install'), + trailing: isDownloading + ? null + : FilledButton( + onPressed: () => + ref.read(updateProvider.notifier).downloadAndInstall(), + child: const Text('Install'), + ), + ); + } } diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..eb768d3 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) open_file_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); + open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..eb06039 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + open_file_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index f64f657..104d10e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,13 @@ import FlutterMacOS import Foundation import flutter_inappwebview_macos +import open_file_mac +import package_info_plus import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 4a97d08..3fe55cf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -288,6 +288,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.1" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" http_parser: dependency: transitive description: @@ -408,6 +416,86 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + open_file: + dependency: "direct main" + description: + name: open_file + sha256: b22decdae85b459eac24aeece48f33845c6f16d278a9c63d75c5355345ca236b + url: "https://pub.dev" + source: hosted + version: "3.5.11" + open_file_android: + dependency: transitive + description: + name: open_file_android + sha256: "58141fcaece2f453a9684509a7275f231ac0e3d6ceb9a5e6de310a7dff9084aa" + url: "https://pub.dev" + source: hosted + version: "1.0.6" + open_file_ios: + dependency: transitive + description: + name: open_file_ios + sha256: a5acd07ba1f304f807a97acbcc489457e1ad0aadff43c467987dd9eef814098f + url: "https://pub.dev" + source: hosted + version: "1.0.4" + open_file_linux: + dependency: transitive + description: + name: open_file_linux + sha256: d189f799eecbb139c97f8bc7d303f9e720954fa4e0fa1b0b7294767e5f2d7550 + url: "https://pub.dev" + source: hosted + version: "0.0.5" + open_file_mac: + dependency: transitive + description: + name: open_file_mac + sha256: cd293f6750de6438ab2390513c99128ade8c974825d4d8128886d1cda8c64d01 + url: "https://pub.dev" + source: hosted + version: "1.0.4" + open_file_platform_interface: + dependency: transitive + description: + name: open_file_platform_interface + sha256: "101b424ca359632699a7e1213e83d025722ab668b9fd1412338221bf9b0e5757" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + open_file_web: + dependency: transitive + description: + name: open_file_web + sha256: e3dbc9584856283dcb30aef5720558b90f88036360bd078e494ab80a80130c4f + url: "https://pub.dev" + source: hosted + version: "0.0.4" + open_file_windows: + dependency: transitive + description: + name: open_file_windows + sha256: d26c31ddf935a94a1a3aa43a23f4fff8a5ff4eea395fe7a8cb819cf55431c875 + url: "https://pub.dev" + source: hosted + version: "0.0.3" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: @@ -669,6 +757,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b55d921..32c46f5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -21,6 +21,8 @@ dependencies: shared_preferences: ^2.3.2 flutter_markdown: ^0.7.3 markdown: ^7.2.2 + package_info_plus: ^8.0.0 + open_file: ^3.3.2 flutter_inappwebview: ^6.1.5 dev_dependencies: