Skip to content

Commit e38e239

Browse files
committed
Final commit
1 parent f55ff9b commit e38e239

File tree

7 files changed

+330
-16
lines changed

7 files changed

+330
-16
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,6 @@ app.*.map.json
4646

4747

4848
/android/app/build.gradle.kts
49+
50+
key.properties
51+
manifest.json

android/app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ android {
2727
applicationId = "com.example.instruo_application"
2828
// You can update the following values to match your application needs.
2929
// For more information, see: https://flutter.dev/to/review-gradle-config.
30-
minSdk = 23//flutter.minSdkVersion
30+
minSdk = flutter.minSdkVersion//flutter.minSdkVersion
3131
targetSdk = flutter.targetSdkVersion
3232
versionCode = flutter.versionCode
3333
versionName = flutter.versionName

lib/home_page.dart

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ class _HomePageState extends State<HomePage> {
131131
],
132132
),
133133
child: InkWell(
134-
borderRadius: BorderRadius.circular(16),
134+
borderRadius: BorderRadius.circular(12),
135135
onTap: () {
136136
launchDialer("https://instruo.tech/", context,
137137
isUrl: true);
@@ -202,7 +202,7 @@ class _HomePageState extends State<HomePage> {
202202
Image.asset(
203203
event["image"]!,
204204
fit: BoxFit.cover,
205-
opacity: const AlwaysStoppedAnimation(0.6),
205+
opacity: const AlwaysStoppedAnimation(0.65),
206206
),
207207

208208
Align(

lib/in_app_updater.dart

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
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+
}

macos/Flutter/GeneratedPluginRegistrant.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import firebase_auth
1111
import firebase_core
1212
import firebase_storage
1313
import open_file_mac
14+
import package_info_plus
1415
import path_provider_foundation
1516
import url_launcher_macos
1617

@@ -21,6 +22,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
2122
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
2223
FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin"))
2324
OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin"))
25+
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
2426
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
2527
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
2628
}

0 commit comments

Comments
 (0)