feat: auto-updater

This commit is contained in:
2026-07-29 00:26:38 +02:00
parent e53bdf1c38
commit 4f9781e3ff
11 changed files with 461 additions and 195 deletions
+6
View File
@@ -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")
}
+21
View File
@@ -1,8 +1,29 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application
android:label="kooltab2"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.ota_update_provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/ota_update_paths" />
</provider>
<receiver android:name="sk.fourq.otaupdate.InstallResultReceiver" android:exported="false">
<intent-filter>
<action android:name="${applicationId}.ACTION_INSTALL_COMPLETE"/>
</intent-filter>
</receiver>
<activity
android:name=".MainActivity"
android:exported="true"
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<files-path
name="ota_update"
path="." />
</paths>
-101
View File
@@ -13,107 +13,6 @@ class KoolTabApp extends StatefulWidget {
}
class _KoolTabAppState extends State<KoolTabApp> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkForUpdates();
});
}
Future<void> _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<void> _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) {
+98 -92
View File
@@ -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<File> 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<bool> 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<void> 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<UpdateInfo?> 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<File> 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<bool> 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<void> 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<UpdateInfo?> 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;
// }
}
+143
View File
@@ -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<void> 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<void> _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",
);
}
}
+68
View File
@@ -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<UpdateInfo?> checkForUpdates() async {
if (_checkingForUpdates) return null;
_checkingForUpdates = true;
notifyListeners();
try {
return await _updater.checkForUpdate();
} finally {
_checkingForUpdates = false;
notifyListeners();
}
}
Future<void> 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<void> updatePinRequired(bool value) =>
+16 -1
View File
@@ -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<BarScreenView> createState() => _BarScreenViewState();
}
class _BarScreenViewState extends State<BarScreenView> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
UpdateChecker.check(context);
});
}
@override
Widget build(BuildContext context) {
final viewModel = context.watch<BarScreenViewModel>();
+91
View File
@@ -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<SettingsScreenView> {
),
],
),
_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<void> _checkForUpdates() async {
final vm = context.read<SettingsViewModel>();
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<SettingsViewModel>().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 {
+8
View File
@@ -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:
+2 -1
View File
@@ -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