diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b5f8582..3d7630b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -20,6 +20,8 @@ android { ndkVersion = flutter.ndkVersion compileOptions { + isCoreLibraryDesugaringEnabled = true + sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -60,3 +62,7 @@ kotlin { flutter { source = "../.." } + +dependencies { + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5f6e15a..a401e87 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,29 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/lib/app/app.dart b/lib/app/app.dart index af88665..e52b776 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -13,107 +13,6 @@ class KoolTabApp extends StatefulWidget { } class _KoolTabAppState extends State { - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) { - _checkForUpdates(); - }); - } - - Future _checkForUpdates() async { - final updater = AppUpdateUtil(serverUrl: "http://localhost:3000"); - - try { - final update = await updater.checkForUpdate(); - - if (update == null) { - debugPrint("No update available"); - return; - } - - debugPrint("Update available: ${update.version}"); - - if (!mounted) return; - - _showUpdateDialog(update); - } catch (e) { - debugPrint("Update check failed: $e"); - } - } - - void _showUpdateDialog(UpdateInfo update) { - showDialog( - context: context, - barrierDismissible: !update.mandatory, - builder: (context) { - return AlertDialog( - title: const Text("Update available"), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - "Version ${update.version} is available.", - ), - const SizedBox(height: 12), - Text(update.notes), - ], - ), - actions: [ - if (!update.mandatory) - TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text("Later"), - ), - - FilledButton( - onPressed: () async { - Navigator.pop(context); - - await _downloadAndInstall(update); - }, - child: const Text("Update"), - ), - ], - ); - }, - ); - } - - Future _downloadAndInstall(UpdateInfo update) async { - final updater = AppUpdateUtil( - serverUrl: "http://localhost:3000", - ); - - try { - final apk = await updater.downloadApk( - update, - onProgress: (progress) { - debugPrint( - "Download ${(progress * 100).toStringAsFixed(0)}%", - ); - }, - ); - - final valid = await updater.verifySha256( - apk, - update.sha256, - ); - - if (!valid) { - throw Exception("Invalid update file"); - } - - await updater.installApk(apk); - - } catch (e) { - debugPrint("Update failed: $e"); - } - } @override Widget build(BuildContext context) { diff --git a/lib/utils/app_update_util.dart b/lib/utils/app_update_util.dart index 641e725..9a4dc3a 100644 --- a/lib/utils/app_update_util.dart +++ b/lib/utils/app_update_util.dart @@ -58,6 +58,8 @@ class AppUpdateUtil { final response = await http.get(url); + debugPrint("Response from update server: ${response.body}"); + if (response.statusCode != 200) { throw Exception("Update server unavailable"); } @@ -71,96 +73,100 @@ class AppUpdateUtil { return UpdateInfo.fromJson(data); } - /// Downloads the APK - Future downloadApk( - UpdateInfo update, { - - Function(double progress)? onProgress, - }) async { - final url = update.download.startsWith("http") - ? update.download - : "$serverUrl${update.download}"; - - final request = http.Request("GET", Uri.parse(url)); - - final response = await request.send(); - - if (response.statusCode != 200) { - throw Exception("APK download failed"); - } - - final total = response.contentLength ?? 0; - - int received = 0; - - final directory = await getTemporaryDirectory(); - - final file = File("${directory.path}/update.apk"); - - final sink = file.openWrite(); - - await for (final chunk in response.stream) { - sink.add(chunk); - - received += chunk.length; - - if (total > 0 && onProgress != null) { - onProgress(received / total); - } - } - - await sink.close(); - - return file; - } - - /// Verifies APK checksum - Future verifySha256(File file, String expectedHash) async { - final bytes = await file.readAsBytes(); - - final digest = sha256.convert(bytes); - - return digest.toString().toLowerCase() == expectedHash.toLowerCase(); - } - - /// Opens Android APK installer - Future installApk(File file) async { - // final result = - // await OpenFilex.open( - // file.path, - // ); - // - // - // if (result.type != ResultType.done) { - // throw Exception( - // "Could not open APK installer", - // ); - // } - } - - /// Full update flow - /// - /// Returns: - /// - null if no update exists - /// - UpdateInfo if update is available - /// - Future update({Function(double progress)? onProgress}) async { - final info = await checkForUpdate(); - - if (info == null) { - return null; - } - - final apk = await downloadApk(info, onProgress: onProgress); - - final valid = await verifySha256(apk, info.sha256); - - if (!valid) { - throw Exception("APK checksum mismatch"); - } - - await installApk(apk); - - return info; - } + // /// Downloads the APK + // Future downloadApk( + // UpdateInfo update, { + // + // Function(double progress)? onProgress, + // }) async { + // final url = update.download.startsWith("http") + // ? update.download + // : "$serverUrl${update.download}"; + // + // final request = http.Request("GET", Uri.parse(url)); + // + // final response = await request.send(); + // + // if (response.statusCode != 200) { + // throw Exception("APK download failed"); + // } + // + // final total = response.contentLength ?? 0; + // + // int received = 0; + // + // final directory = await getTemporaryDirectory(); + // + // final file = File("${directory.path}/update.apk"); + // + // final sink = file.openWrite(); + // + // await for (final chunk in response.stream) { + // sink.add(chunk); + // + // received += chunk.length; + // + // if (total > 0 && onProgress != null) { + // onProgress(received / total); + // } + // } + // + // await sink.close(); + // + // return file; + // } + // + // /// Verifies APK checksum + // Future verifySha256(File file, String expectedHash) async { + // final bytes = await file.readAsBytes(); + // + // final digest = sha256.convert(bytes); + // + // return digest.toString().toLowerCase() == expectedHash.toLowerCase(); + // } + // + // /// Opens Android APK installer + // Future installApk(File file) async { + // final result = + // await OpenFilex.open( + // file.path, + // ); + // + // if(result.type == ResultType.done) { + // debugPrint("APK installer opened successfully"); + // } + // + // + // if (result.type != ResultType.done) { + // throw Exception( + // "Could not open APK installer", + // ); + // } + // } + // + // /// Full update flow + // /// + // /// Returns: + // /// - null if no update exists + // /// - UpdateInfo if update is available + // /// + // Future update({Function(double progress)? onProgress}) async { + // final info = await checkForUpdate(); + // + // if (info == null) { + // return null; + // } + // + // final apk = await downloadApk(info, onProgress: onProgress); + // + // final valid = await verifySha256(apk, info.sha256); + // + // if (!valid) { + // throw Exception("APK checksum mismatch"); + // } + // + // await installApk(apk); + // + // return info; + // } } diff --git a/lib/utils/app_updater.dart b/lib/utils/app_updater.dart new file mode 100644 index 0000000..2e26be5 --- /dev/null +++ b/lib/utils/app_updater.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:kooltab2/utils/app_update_util.dart'; +import 'package:ota_update/ota_update.dart'; + +class UpdateChecker { + static bool _hasChecked = false; + + static Future check(BuildContext context) async { + if (_hasChecked) { + debugPrint("Update already checked this boot"); + return; + } + + _hasChecked = true; + + final updater = AppUpdateUtil( + serverUrl: "https://updater.brammie15.dev", + ); + + try { + final update = await updater.checkForUpdate(); + + if (update == null) { + debugPrint("No update available"); + return; + } + + debugPrint("Update available: ${update.version}"); + + if (!context.mounted) return; + + _showUpdateDialog(context, update); + } catch (e) { + debugPrint("Update check failed: $e"); + } + } + + static void _showUpdateDialog( + BuildContext context, + UpdateInfo update, + ) { + showDialog( + context: context, + barrierDismissible: !update.mandatory, + builder: (_) => AlertDialog( + title: const Text("Update available"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Version ${update.version} is available."), + const SizedBox(height: 12), + Text(update.notes), + ], + ), + actions: [ + if (!update.mandatory) + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Later"), + ), + + FilledButton( + onPressed: () async { + Navigator.pop(context); + await _downloadAndInstall(update); + }, + child: const Text("Update"), + ), + ], + ), + ); + } +} + +Future _downloadAndInstall(UpdateInfo update) async { + try { + final url = update.download.startsWith("http") + ? update.download + : "https://updater.brammie15.dev${update.download}"; + OtaUpdate() + .execute( + url, + destinationFilename: "update.apk", + sha256checksum: update.sha256, + ) + .listen( + (OtaEvent event) { + + debugPrint( + "OTA status: ${event.status}", + ); + + debugPrint( + "OTA value: ${event.value}", + ); + + switch(event.status) { + + case OtaStatus.DOWNLOADING: + final progress = + double.tryParse(event.value ?? "0") ?? 0; + + debugPrint( + "Downloading ${progress.toStringAsFixed(0)}%", + ); + + break; + + + case OtaStatus.INSTALLING: + debugPrint( + "Installing update", + ); + break; + + + case OtaStatus.INSTALLATION_ERROR: + debugPrint( + "Installation error: ${event.value}", + ); + break; + + + case OtaStatus.DOWNLOAD_ERROR: + debugPrint( + "Download error: ${event.value}", + ); + break; + + + default: + break; + } + }, + ); + + } catch (e) { + debugPrint( + "OTA update failed: $e", + ); + } +} \ No newline at end of file diff --git a/lib/viewmodels/settings_view_model.dart b/lib/viewmodels/settings_view_model.dart index dd04bb0..4a8a7bc 100644 --- a/lib/viewmodels/settings_view_model.dart +++ b/lib/viewmodels/settings_view_model.dart @@ -1,4 +1,6 @@ import 'package:flutter/foundation.dart'; +import 'package:kooltab2/utils/app_update_util.dart'; +import 'package:ota_update/ota_update.dart'; import '../models/settings.dart'; import '../services/settings_service.dart'; @@ -13,6 +15,14 @@ class SettingsViewModel extends ChangeNotifier { bool _hasLoaded = false; String? _errorMessage; + final AppUpdateUtil _updater = AppUpdateUtil( + serverUrl: "https://updater.brammie15.dev", + ); + + bool _checkingForUpdates = false; + + bool get checkingForUpdates => _checkingForUpdates; + AppSettings get settings => _settings; bool get isLoading => _isLoading; bool get hasLoaded => _hasLoaded; @@ -41,6 +51,64 @@ class SettingsViewModel extends ChangeNotifier { } } + Future checkForUpdates() async { + if (_checkingForUpdates) return null; + + _checkingForUpdates = true; + notifyListeners(); + + try { + return await _updater.checkForUpdate(); + } finally { + _checkingForUpdates = false; + notifyListeners(); + } + } + + Future installUpdate( + UpdateInfo update, { + void Function(double progress)? onProgress, + }) async { + + final url = update.download.startsWith("http") + ? update.download + : "https://updater.brammie15.dev${update.download}"; + + debugPrint("Download Url: ${url}"); + + final stream = OtaUpdate().execute( + url, + destinationFilename: "update.apk", + sha256checksum: update.sha256, + ); + + await for (final event in stream) { + + debugPrint( + "OTA: ${event.status} ${event.value}", + ); + + if (event.status == OtaStatus.DOWNLOADING) { + final progress = + double.tryParse(event.value ?? "0") ?? 0; + + onProgress?.call(progress / 100); + } + + if (event.status == OtaStatus.DOWNLOAD_ERROR) { + throw Exception( + "Download failed: ${event.value}", + ); + } + + if (event.status == OtaStatus.INSTALLATION_ERROR) { + throw Exception( + "Installation failed: ${event.value}", + ); + } + } + } + /// Just flips the preference flag. Caller is responsible for having /// already set/verified the actual PIN via PinLockViewModel first. Future updatePinRequired(bool value) => diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index 5ceb2c4..750b56f 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -11,11 +11,26 @@ import 'package:flutter_slidable/flutter_slidable.dart'; import '../models/bar_tab.dart'; import '../models/product.dart'; import '../models/tab_item.dart'; +import '../utils/app_updater.dart'; import '../viewmodels/bar_screen_view_model.dart'; -class BarScreenView extends StatelessWidget { +class BarScreenView extends StatefulWidget { const BarScreenView({super.key}); + @override + State createState() => _BarScreenViewState(); +} + +class _BarScreenViewState extends State { + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + UpdateChecker.check(context); + }); + } + @override Widget build(BuildContext context) { final viewModel = context.watch(); diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index ab190ab..5d802fa 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../models/settings.dart'; +import '../utils/app_update_util.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/settings_view_model.dart'; @@ -213,12 +214,102 @@ class _SettingsScreenViewState extends State { ), ], ), + _SettingsSection( + title: 'Updates', + children: [ + _SettingsTile( + icon: Icons.system_update_alt_rounded, + title: settingsViewModel.checkingForUpdates + ? 'Checking for updates...' + : 'Check for updates', + onTap: settingsViewModel.checkingForUpdates + ? null + : _checkForUpdates, + ), + ], + ), ], ); }, ), ); } + + Future _checkForUpdates() async { + final vm = context.read(); + + try { + final update = await vm.checkForUpdates(); + + if (!mounted) return; + + if (update == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('You are already on the latest version.'), + ), + ); + return; + } + + _showUpdateDialog(update); + } catch (e) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Update check failed: $e')), + ); + } + } + + void _showUpdateDialog(UpdateInfo update) { + showDialog( + context: context, + barrierDismissible: !update.mandatory, + builder: (context) => AlertDialog( + title: const Text("Update available"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Version ${update.version} is available."), + const SizedBox(height: 12), + Text(update.notes), + ], + ), + actions: [ + if (!update.mandatory) + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text("Later"), + ), + FilledButton( + onPressed: () async { + Navigator.pop(context); + + try { + await context.read().installUpdate( + update, + onProgress: (progress) { + debugPrint( + "Download ${(progress * 100).toStringAsFixed(0)}%", + ); + }, + ); + } catch (e) { + if (!mounted) return; + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Update failed: $e")), + ); + } + }, + child: const Text("Update"), + ), + ], + ), + ); + } } class _SettingsSection extends StatelessWidget { diff --git a/pubspec.lock b/pubspec.lock index a4c49e6..b930472 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -632,6 +632,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.7.0" + ota_update: + dependency: "direct main" + description: + name: ota_update + sha256: "1f4c7c3c4f306729a6c00b84435096ce2d8b28439013f7237173acc699b2abc8" + url: "https://pub.dev" + source: hosted + version: "7.1.0" package_config: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 18f1900..6368ea0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+1 +version: 1.0.0+1 environment: sdk: ^3.12.2 @@ -49,6 +49,7 @@ dependencies: http: ^1.6.0 package_info_plus: ^10.1.0 open_filex: ^4.7.0 + ota_update: ^7.1.0 # permission_handler: ^12.0.3