|
| 1 | +// in_app_updater.dart |
| 2 | +import 'dart:convert'; |
| 3 | +import 'dart:io'; |
| 4 | +import 'package:crypto/crypto.dart'; |
| 5 | +import 'package:dio/dio.dart'; |
| 6 | +import 'package:flutter/material.dart'; |
| 7 | +import 'package:http/http.dart' as http; |
| 8 | +import 'package:open_file/open_file.dart'; |
| 9 | +import 'package:package_info_plus/package_info_plus.dart'; |
| 10 | +import 'package:path_provider/path_provider.dart'; |
| 11 | +import 'package:permission_handler/permission_handler.dart'; |
| 12 | +import 'package:android_intent_plus/android_intent.dart'; |
| 13 | + |
| 14 | +class InAppUpdater { |
| 15 | + final String manifestUrl; |
| 16 | + final Dio _dio = Dio(); |
| 17 | + |
| 18 | + InAppUpdater({required this.manifestUrl}); |
| 19 | + |
| 20 | + Future<Map<String, dynamic>?> _fetchManifest() async { |
| 21 | + try { |
| 22 | + final r = await http.get(Uri.parse(manifestUrl)); |
| 23 | + if (r.statusCode == 200) { |
| 24 | + return json.decode(r.body) as Map<String, dynamic>; |
| 25 | + } else { |
| 26 | + debugPrint('Manifest fetch failed: ${r.statusCode}'); |
| 27 | + } |
| 28 | + } catch (e) { |
| 29 | + debugPrint('Manifest fetch error: $e'); |
| 30 | + } |
| 31 | + return null; |
| 32 | + } |
| 33 | + |
| 34 | + Future<int> _currentVersionCode() async { |
| 35 | + final info = await PackageInfo.fromPlatform(); |
| 36 | + return int.tryParse(info.buildNumber) ?? 0; |
| 37 | + } |
| 38 | + |
| 39 | + Future<bool> isUpdateAvailableFromManifest(Map<String, dynamic> manifest) async { |
| 40 | + final remote = (manifest['versionCode'] is int) |
| 41 | + ? manifest['versionCode'] as int |
| 42 | + : int.tryParse(manifest['versionCode'].toString()) ?? 0; |
| 43 | + final local = await _currentVersionCode(); |
| 44 | + return remote > local; |
| 45 | + } |
| 46 | + |
| 47 | + Future<String> _sha256File(String path) async { |
| 48 | + final bytes = await File(path).readAsBytes(); |
| 49 | + final digest = sha256.convert(bytes); |
| 50 | + return digest.toString(); |
| 51 | + } |
| 52 | + |
| 53 | + Future<String> _downloadApk(String url, void Function(int, int)? onProgress) async { |
| 54 | + final dir = (await getExternalStorageDirectory()) ?? await getApplicationDocumentsDirectory(); |
| 55 | + final filePath = '${dir!.path}/instruo_update.apk'; |
| 56 | + final file = File(filePath); |
| 57 | + if (await file.exists()) await file.delete(); |
| 58 | + await _dio.download(url, filePath, |
| 59 | + onReceiveProgress: onProgress, |
| 60 | + options: Options(receiveTimeout: Duration.zero, followRedirects: true)); |
| 61 | + return filePath; |
| 62 | + } |
| 63 | + |
| 64 | + Future<void> _openUnknownAppsSettings(BuildContext context) async { |
| 65 | + final intent = AndroidIntent( |
| 66 | + action: 'android.settings.MANAGE_UNKNOWN_APP_SOURCES', |
| 67 | + data: 'package:${Platform.isAndroid ? await _getPackageName() : ""}', |
| 68 | + ); |
| 69 | + try { |
| 70 | + await intent.launch(); |
| 71 | + } catch (e) { |
| 72 | + ScaffoldMessenger.of(context).showSnackBar( |
| 73 | + SnackBar(content: Text('Open Install Unknown Apps settings manually')), |
| 74 | + ); |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + Future<String> _getPackageName() async { |
| 79 | + final info = await PackageInfo.fromPlatform(); |
| 80 | + return info.packageName; |
| 81 | + } |
| 82 | + |
| 83 | + /// Main public method: checks manifest, prompts user, downloads, verifies and launches installer. |
| 84 | + Future<void> checkAndPerformUpdate(BuildContext context, {bool forceCheck = false}) async { |
| 85 | + final manifest = await _fetchManifest(); |
| 86 | + if (manifest == null) { |
| 87 | + if (forceCheck) { |
| 88 | + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to fetch update manifest'))); |
| 89 | + } |
| 90 | + return; |
| 91 | + } |
| 92 | + |
| 93 | + final apkUrl = manifest['apk_url']?.toString(); |
| 94 | + final expectedSha = manifest['sha256']?.toString()?.toLowerCase(); |
| 95 | + final remoteVersionCode = (manifest['versionCode'] is int) |
| 96 | + ? manifest['versionCode'] as int |
| 97 | + : int.tryParse(manifest['versionCode'].toString()) ?? 0; |
| 98 | + final versionName = manifest['versionName']?.toString() ?? ''; |
| 99 | + |
| 100 | + if (apkUrl == null || expectedSha == null) { |
| 101 | + debugPrint('Manifest missing apk_url or sha256'); |
| 102 | + return; |
| 103 | + } |
| 104 | + |
| 105 | + final localVersion = await _currentVersionCode(); |
| 106 | + if (remoteVersionCode <= localVersion) { |
| 107 | + if (forceCheck) { |
| 108 | + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('App is already up to date'))); |
| 109 | + } |
| 110 | + return; |
| 111 | + } |
| 112 | + |
| 113 | + // Show update dialog |
| 114 | + final shouldDownload = await showDialog<bool>( |
| 115 | + context: context, |
| 116 | + builder: (_) => _UpdateDialog(versionName: versionName), |
| 117 | + ); |
| 118 | + |
| 119 | + if (shouldDownload != true) return; |
| 120 | + |
| 121 | + // Request storage permission (older devices) |
| 122 | + if (Platform.isAndroid) { |
| 123 | + if (!await Permission.storage.isGranted) { |
| 124 | + await Permission.storage.request(); |
| 125 | + } |
| 126 | + } |
| 127 | + |
| 128 | + // Download with progress UI |
| 129 | + String? apkPath; |
| 130 | + final progressNotifier = ValueNotifier<double>(0); |
| 131 | + showDialog( |
| 132 | + context: context, |
| 133 | + barrierDismissible: false, |
| 134 | + builder: (_) => _DownloadProgressDialog(progress: progressNotifier), |
| 135 | + ); |
| 136 | + |
| 137 | + try { |
| 138 | + apkPath = await _downloadApk(apkUrl, (received, total) { |
| 139 | + if (total > 0) { |
| 140 | + progressNotifier.value = received / total; |
| 141 | + } |
| 142 | + }); |
| 143 | + } catch (e) { |
| 144 | + Navigator.of(context).pop(); // close progress dialog |
| 145 | + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Download failed: $e'))); |
| 146 | + return; |
| 147 | + } |
| 148 | + |
| 149 | + // Verify sha256 |
| 150 | + final actualSha = await _sha256File(apkPath); |
| 151 | + if (actualSha.toLowerCase() != expectedSha.toLowerCase()) { |
| 152 | + Navigator.of(context).pop(); // close progress dialog |
| 153 | + await File(apkPath).delete().catchError((_) {}); |
| 154 | + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Checksum mismatch — aborting update'))); |
| 155 | + return; |
| 156 | + } |
| 157 | + |
| 158 | + // Close progress dialog |
| 159 | + Navigator.of(context).pop(); |
| 160 | + |
| 161 | + // On Android 8+ ensure user has allowed install from unknown sources for this app |
| 162 | + bool needsUnknownSourceFlow = false; |
| 163 | + if (Platform.isAndroid) { |
| 164 | + // Can't call PackageManager.canRequestPackageInstalls() directly from Dart (no plugin here). |
| 165 | + // We'll attempt to launch the APK; if install fails user will be guided. |
| 166 | + // Offer the user to open settings proactively: |
| 167 | + needsUnknownSourceFlow = true; |
| 168 | + } |
| 169 | + |
| 170 | + if (needsUnknownSourceFlow) { |
| 171 | + final allow = await showDialog<bool>( |
| 172 | + context: context, |
| 173 | + builder: (_) => AlertDialog( |
| 174 | + title: Text('Install permission required'), |
| 175 | + content: Text('You may need to enable "Install unknown apps" for this app before installing the update. Open settings now?'), |
| 176 | + actions: [ |
| 177 | + TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text('Cancel')), |
| 178 | + TextButton(onPressed: () => Navigator.of(context).pop(true), child: Text('Open Settings')), |
| 179 | + ], |
| 180 | + ), |
| 181 | + ); |
| 182 | + if (allow == true) { |
| 183 | + await _openUnknownAppsSettings(context); |
| 184 | + // Wait for user to come back — we can't detect when they return reliably, so ask them to press Install after enabling. |
| 185 | + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('After enabling, tap the Update button again to install.'))); |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + // Launch installer (this will prompt user to confirm install) |
| 190 | + final result = await OpenFile.open(apkPath); |
| 191 | + debugPrint('OpenFile result: $result'); |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +// Small dialog classes used above |
| 196 | +class _UpdateDialog extends StatelessWidget { |
| 197 | + final String versionName; |
| 198 | + const _UpdateDialog({Key? key, required this.versionName}) : super(key: key); |
| 199 | + @override |
| 200 | + Widget build(BuildContext context) { |
| 201 | + return AlertDialog( |
| 202 | + title: Text('Update available'), |
| 203 | + content: Text('A new version ($versionName) is available. Do you want to download and install it?'), |
| 204 | + actions: [ |
| 205 | + TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text('Later')), |
| 206 | + ElevatedButton(onPressed: () => Navigator.of(context).pop(true), child: Text('Update')), |
| 207 | + ], |
| 208 | + ); |
| 209 | + } |
| 210 | +} |
| 211 | + |
| 212 | +class _DownloadProgressDialog extends StatelessWidget { |
| 213 | + final ValueNotifier<double> progress; |
| 214 | + const _DownloadProgressDialog({Key? key, required this.progress}) : super(key: key); |
| 215 | + @override |
| 216 | + Widget build(BuildContext context) { |
| 217 | + return Dialog( |
| 218 | + child: Padding( |
| 219 | + padding: EdgeInsets.all(16), |
| 220 | + child: ValueListenableBuilder<double>( |
| 221 | + valueListenable: progress, |
| 222 | + builder: (_, value, __) { |
| 223 | + final percent = (value * 100).toStringAsFixed(0); |
| 224 | + return Column( |
| 225 | + mainAxisSize: MainAxisSize.min, |
| 226 | + children: [ |
| 227 | + Text('Downloading update... $percent%'), |
| 228 | + SizedBox(height: 12), |
| 229 | + LinearProgressIndicator(value: value), |
| 230 | + SizedBox(height: 12), |
| 231 | + Text('Do not close the app while downloading'), |
| 232 | + SizedBox(height: 6), |
| 233 | + TextButton( |
| 234 | + onPressed: () { |
| 235 | + // No cancel implemented (Dio cancel token would be required for cancel) |
| 236 | + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Cancel not implemented'))); |
| 237 | + }, |
| 238 | + child: Text('Cancel'), |
| 239 | + ), |
| 240 | + ], |
| 241 | + ); |
| 242 | + }, |
| 243 | + ), |
| 244 | + ), |
| 245 | + ); |
| 246 | + } |
| 247 | +} |
0 commit comments