From 216d91be93852007da8b86e6ab9fc129f5f1312e Mon Sep 17 00:00:00 2001 From: DJDoubleD <34967020+DJDoubleD@users.noreply.github.com> Date: Wed, 31 Jul 2024 20:21:21 +0200 Subject: [PATCH] Enable automatic update checks, download and install - Enable update check with notification - Enable update screen with releasenotes formatted from github markdown - Update dependencies --- android/app/src/main/AndroidManifest.xml | 1 + .../main/java/r/r/refreezer/MainActivity.java | 26 ++ lib/languages/en_us.dart | 3 + lib/main.dart | 8 +- lib/ui/settings_screen.dart | 1 - lib/ui/updater.dart | 339 ++++++++++++------ pubspec.yaml | 3 +- 7 files changed, 268 insertions(+), 113 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 30176ce..beb1de5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ + diff --git a/android/app/src/main/java/r/r/refreezer/MainActivity.java b/android/app/src/main/java/r/r/refreezer/MainActivity.java index b1f36e5..b0377cf 100644 --- a/android/app/src/main/java/r/r/refreezer/MainActivity.java +++ b/android/app/src/main/java/r/r/refreezer/MainActivity.java @@ -6,6 +6,7 @@ import android.content.Intent; import android.content.ServiceConnection; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; @@ -15,6 +16,7 @@ import android.os.Message; import android.os.Messenger; import android.os.Parcelable; import android.os.RemoteException; +import android.provider.Settings; import android.util.Log; import androidx.annotation.NonNull; @@ -217,6 +219,17 @@ public class MainActivity extends AudioServiceActivity { result.success(null); return; } + // Check if can request package install permission + if (call.method.equals("checkInstallPackagesPermission")) { + result.success(canRequestPackageInstalls()); + return; + } + // Request package install permission + if (call.method.equals("requestInstallPackagesPermission")) { + requestInstallPackagesPermission(); + result.success(true); + return; + } result.error("0", "Not implemented!", "Not implemented!"); })); @@ -236,6 +249,19 @@ public class MainActivity extends AudioServiceActivity { })); } + private boolean canRequestPackageInstalls() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return getPackageManager().canRequestPackageInstalls(); + } + return true; + } + + private void requestInstallPackagesPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startActivity(new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getPackageName()))); + } + } + //Start/Bind/Reconnect to download service private void connectService() { if (serviceBound) diff --git a/lib/languages/en_us.dart b/lib/languages/en_us.dart index 72058a3..2bdda4b 100644 --- a/lib/languages/en_us.dart +++ b/lib/languages/en_us.dart @@ -441,5 +441,8 @@ const language_en_us = { 'Open system settings': 'Open system settings', 'Application Log': 'Application Log', 'Are you sure you want to log out?': 'Are you sure you want to log out?', + + // 0.7.13 + 'Download failed!': 'Download failed!', } }; diff --git a/lib/main.dart b/lib/main.dart index c335d72..24af100 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,7 @@ import 'ui/home_screen.dart'; import 'ui/library.dart'; import 'ui/login_screen.dart'; import 'ui/player_bar.dart'; -//import 'ui/updater.dart'; +import 'ui/updater.dart'; import 'ui/search.dart'; import 'utils/logging.dart'; import 'utils/navigator_keys.dart'; @@ -243,11 +243,9 @@ class _MainScreenState extends State _prepareQuickActions(); //Check for updates on background - /* No automatic updates yet - Future.delayed(Duration(seconds: 5), () { - FreezerVersions.checkUpdate(); + Future.delayed(const Duration(seconds: 5), () { + ReFreezerLatest.checkUpdate(); }); - */ //Restore saved queue _loadSavedQueue(); diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index dea82ed..83737cb 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -143,7 +143,6 @@ class _SettingsScreenState extends State { leading: const LeadingIcon(Icons.update, color: Color(0xff2ba766)), onTap: () => Navigator.push(context, MaterialPageRoute(builder: (context) => const UpdaterScreen())), - enabled: false, ), ListTile( title: Text('About'.i18n), diff --git a/lib/ui/updater.dart b/lib/ui/updater.dart index 97f9f28..4a4ca92 100644 --- a/lib/ui/updater.dart +++ b/lib/ui/updater.dart @@ -2,10 +2,13 @@ import 'dart:convert'; import 'dart:io'; import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:http/http.dart' as http; +import 'package:logging/logging.dart'; import 'package:open_filex/open_filex.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:path/path.dart' as p; @@ -26,65 +29,113 @@ class UpdaterScreen extends StatefulWidget { } class _UpdaterScreenState extends State { + static const MethodChannel _platform = MethodChannel('r.r.refreezer/native'); bool _loading = true; bool _error = false; - FreezerVersions? _versions; - String? _current; + ReFreezerLatest? _latestRelease; + Version _currentVersion = Version.parse('0.0.0'); String? _arch; double _progress = 0.0; bool _buttonEnabled = true; + static Future _checkInstallPackagesPermission() async { + try { + return await _platform.invokeMethod('checkInstallPackagesPermission'); + } catch (e) { + Logger.root.severe('Failed to check install packages permission', e); + return false; + } + } + + static Future _requestInstallPackagesPermission() async { + try { + await _platform.invokeMethod('requestInstallPackagesPermission'); + } catch (e) { + Logger.root.severe('Failed to request install packages permission', e); + } + } + Future _load() async { - //Load current version + // Load current version (convert DEBUG mode to SemVer) PackageInfo info = await PackageInfo.fromPlatform(); - setState(() => _current = info.version); + String versionString = info.version.replaceFirst(' DEBUG', '-debug'); + + // Parse the version string + setState(() { + _currentVersion = + Version.tryParse(versionString) ?? Version.parse('0.0.0'); + }); //Get architecture _arch = await DownloadManager.platform.invokeMethod('arch'); - if (_arch == 'armv8l') _arch = 'arm32'; //Load from website try { - FreezerVersions versions = await FreezerVersions.fetch(); + ReFreezerLatest latestRelease = await ReFreezerLatest.fetch(); setState(() { - _versions = versions; + _latestRelease = latestRelease; _loading = false; }); } catch (e, st) { - if (kDebugMode) { - print(e.toString() + st.toString()); - } + Logger.root.severe('Failed to load latest release', e, st); _error = true; _loading = false; } } - FreezerDownload? get _versionDownload { - return _versions?.versions[0].downloads.firstWhere((d) => d.version.toLowerCase().contains(_arch!.toLowerCase())); + ReFreezerDownload? get _versionDownload { + return _latestRelease?.downloads.firstWhereOrNull( + (d) => d.architectures + .any((arch) => arch.toLowerCase() == _arch?.toLowerCase()), + ); } Future _download() async { - String? url = _versionDownload?.directUrl; - //Start request - http.Client client = http.Client(); - http.StreamedResponse res = await client.send(http.Request('GET', Uri.parse(url ?? ''))); - int? size = res.contentLength; - //Open file - String path = p.join((await getExternalStorageDirectory())!.path, 'update.apk'); - File file = File(path); - IOSink fileSink = file.openWrite(); - //Update progress - Future.doWhile(() async { - int received = await file.length(); - setState(() => _progress = received / size!.toInt()); - return received != size; - }); - //Pipe - await res.stream.pipe(fileSink); - fileSink.close(); + if (!await _checkInstallPackagesPermission()) { + await _requestInstallPackagesPermission(); + } - OpenFilex.open(path); - setState(() => _buttonEnabled = true); + try { + String? url = _versionDownload?.directUrl; + if (url == null) { + throw Exception('No compatible download available'); + } + //Start request + http.Client client = http.Client(); + http.StreamedResponse res = + await client.send(http.Request('GET', Uri.parse(url))); + int? size = res.contentLength; + //Open file + String path = + p.join((await getExternalStorageDirectory())!.path, 'update.apk'); + File file = File(path); + IOSink fileSink = file.openWrite(); + //Update progress + Future.doWhile(() async { + int received = await file.length(); + setState(() => _progress = received / size!.toInt()); + return received != size; + }); + //Pipe + await res.stream.pipe(fileSink); + fileSink.close(); + + OpenFilex.open(path); + setState(() { + _buttonEnabled = true; + _progress = 0.0; + }); + } catch (e) { + Logger.root.severe('Failed to download latest release file', e); + Fluttertoast.showToast( + msg: 'Download failed!'.i18n, + toastLength: Toast.LENGTH_LONG, + gravity: ToastGravity.BOTTOM); + setState(() { + _progress = 0.0; + _buttonEnabled = true; + }); + } } @override @@ -110,42 +161,60 @@ class _UpdaterScreenState extends State { ), if (!_error && !_loading && - Version.parse((_versions?.latest.toString() ?? '0.0.0')) <= Version.parse(_current!)) + (_latestRelease?.version ?? Version(0, 0, 0)) <= + _currentVersion) Center( child: Padding( padding: const EdgeInsets.all(8.0), child: Text('You are running latest version!'.i18n, - textAlign: TextAlign.center, style: const TextStyle(fontSize: 26.0)), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 26.0)), )), if (!_error && !_loading && - Version.parse((_versions?.latest.toString() ?? '0.0.0')) > Version.parse(_current!)) + (_latestRelease?.version ?? Version(0, 0, 0)) > _currentVersion) Column( children: [ Padding( padding: const EdgeInsets.all(8.0), child: Text( - 'New update available!'.i18n + ' ' + _versions!.latest.toString(), + 'New update available!'.i18n + + ' ' + + _latestRelease!.version.toString(), textAlign: TextAlign.center, - style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), + style: const TextStyle( + fontSize: 20.0, fontWeight: FontWeight.bold), ), ), Text( - 'Current version: ' + _current!, - style: const TextStyle(fontSize: 14.0, fontStyle: FontStyle.italic), + 'Current version: ' + _currentVersion.toString(), + style: const TextStyle( + fontSize: 14.0, fontStyle: FontStyle.italic), ), Container(height: 8.0), const FreezerDivider(), Container(height: 8.0), const Text( 'Changelog', - style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), + style: + TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold), ), + Container(height: 8.0), + const FreezerDivider(), Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - _versions!.versions[0].changelog, + /*child: Text( + _latestRelease?.changelog ?? '', style: const TextStyle(fontSize: 16.0), + ),*/ + child: Container( + constraints: const BoxConstraints( + maxHeight: 350 // Screen Title height, ... + ), + child: Markdown( + data: _latestRelease?.changelog ?? '', + shrinkWrap: true, + ), ), ), const FreezerDivider(), @@ -154,16 +223,23 @@ class _UpdaterScreenState extends State { if (_versionDownload != null) Column(children: [ ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).primaryColor, + ), onPressed: _buttonEnabled ? () { setState(() => _buttonEnabled = false); _download(); } : null, - child: Text('Download'.i18n + ' (${_versionDownload?.version})')), + child: Text( + 'Download'.i18n + ' (${_versionDownload?.abi})')), Padding( padding: const EdgeInsets.all(8.0), - child: LinearProgressIndicator(value: _progress), + child: LinearProgressIndicator( + value: _progress, + color: Theme.of(context).primaryColor, + ), ) ]), //Unsupported arch @@ -180,79 +256,130 @@ class _UpdaterScreenState extends State { } } -class FreezerVersions { - String latest; - List versions; +class ReFreezerLatest { + final String versionString; + final Version version; + final String changelog; + final List downloads; - FreezerVersions({required this.latest, required this.versions}); + static const Map> abiMap = { + 'arm64-v8a': ['arm64', 'aarch64'], + 'armeabi-v7a': ['arm32', 'armhf', 'armv8l'], + 'x86_64': ['x86_64'], + }; - factory FreezerVersions.fromJson(Map data) => FreezerVersions( - latest: data['android']['latest'], - versions: data['android']['versions'].map((v) => FreezerVersion.fromJson(v)).toList()); + ReFreezerLatest({ + required this.versionString, + required this.changelog, + required this.downloads, + }) : version = Version.tryParse(versionString) ?? Version.parse('0.0.0'); - //Fetch from website API - static Future fetch() async { - http.Response response = await http.get('https://freezer.life/api/versions' as Uri); -// http.Response response = await http.get('https://cum.freezerapp.workers.dev/api/versions'); - return FreezerVersions.fromJson(jsonDecode(response.body)); + static Future fetch() async { + http.Response res = await http.get( + Uri.parse( + 'https://api.github.com/repos/DJDoubleD/refreezer/releases/latest'), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ); + + if (res.statusCode != 200) { + throw Exception( + 'Failed to load latest version from Github API: $res.statusCode $res.statusMessage'); + } + + Map data = jsonDecode(res.body); + + List downloads = (data['assets'] as List) + .map((asset) { + String abi = abiMap.keys.firstWhere( + (key) => asset['name'].contains(key), + orElse: () => 'unknown', + ); + return ReFreezerDownload( + abi: abi, + directUrl: asset['browser_download_url'], + ); + }) + .where((download) => download.abi != 'unknown') + .toList(); + + return ReFreezerLatest( + versionString: data['tag_name'], + changelog: data['body'], + downloads: downloads, + ); } - static Future checkUpdate() async { - //Check only each 24h - int updateDelay = 86400000; - if ((DateTime.now().millisecondsSinceEpoch - (cache.lastUpdateCheck ?? 0)) < updateDelay) return; - cache.lastUpdateCheck = DateTime.now().millisecondsSinceEpoch; + static Future checkUpdate() async { + final now = DateTime.now().millisecondsSinceEpoch; + + // Check every 24 hours + if (now - (cache.lastUpdateCheck ?? 0) <= + const Duration(hours: 24).inMilliseconds) { + return; + } + cache.lastUpdateCheck = now; await cache.save(); - FreezerVersions versions = await FreezerVersions.fetch(); + try { + final latestVersion = await fetch(); - //Load current version - PackageInfo info = await PackageInfo.fromPlatform(); - if (Version.parse(versions.latest) <= Version.parse(info.version)) return; + //Load current version + final packageInfo = await PackageInfo.fromPlatform(); + final currentVersion = + Version.tryParse(packageInfo.version) ?? Version.parse('0.0.0'); - //Get architecture - String arch = await DownloadManager.platform.invokeMethod('arch'); - if (arch == 'armv8l') arch = 'arm32'; - //Check compatible architecture - var compatibleVersion = - versions.versions[0].downloads.firstWhereOrNull((d) => d.version.toLowerCase().contains(arch.toLowerCase())); - if (compatibleVersion == null) return; + if (latestVersion.version <= currentVersion) return; - //Show notification - FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); - const AndroidInitializationSettings androidInitializationSettings = - AndroidInitializationSettings('drawable/ic_logo'); - const InitializationSettings initializationSettings = - InitializationSettings(android: androidInitializationSettings); - await flutterLocalNotificationsPlugin.initialize(initializationSettings); - AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( - 'freezerupdates', 'Freezer Updates'.i18n, - channelDescription: 'Freezer Updates'.i18n); - NotificationDetails notificationDetails = NotificationDetails(android: androidNotificationDetails); - await flutterLocalNotificationsPlugin.show( - 0, 'New update available!'.i18n, 'Update to latest version in the settings.'.i18n, notificationDetails); + //Get architecture + String arch = await DownloadManager.platform.invokeMethod('arch'); + Logger.root + .info('Checking for updates to version $currentVersion on $arch'); + + if (!latestVersion.downloads.any((download) => download.architectures.any( + (architecture) => + architecture.toLowerCase() == arch.toLowerCase()))) { + Logger.root.warning('No assets found for architecture $arch'); + return; + } + + //Show notification + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); + const AndroidInitializationSettings androidInitializationSettings = + AndroidInitializationSettings('drawable/ic_logo'); + const InitializationSettings initializationSettings = + InitializationSettings( + android: androidInitializationSettings, iOS: null); + await flutterLocalNotificationsPlugin.initialize(initializationSettings); + + AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails( + 'freezerupdates', + 'Freezer Updates'.i18n, + channelDescription: 'Freezer Updates'.i18n, + importance: Importance.high, + priority: Priority.high, + ); + + NotificationDetails notificationDetails = + NotificationDetails(android: androidNotificationDetails, iOS: null); + + await flutterLocalNotificationsPlugin.show( + 0, + 'New update available!'.i18n, + 'Update to latest version in the settings.'.i18n, + notificationDetails); + } catch (e) { + Logger.root.severe('Error checking for updates', e); + } } } -class FreezerVersion { - String version; - String changelog; - List downloads; +class ReFreezerDownload { + final String abi; + final String directUrl; + final List architectures; - FreezerVersion({required this.version, required this.changelog, required this.downloads}); - - factory FreezerVersion.fromJson(Map data) => FreezerVersion( - version: data['version'], - changelog: data['changelog'], - downloads: data['downloads'].map((d) => FreezerDownload.fromJson(d)).toList()); -} - -class FreezerDownload { - String version; - String directUrl; - - FreezerDownload({required this.version, required this.directUrl}); - - factory FreezerDownload.fromJson(Map data) => - FreezerDownload(version: data['version'], directUrl: data['links'].first['url']); + ReFreezerDownload({required this.abi, required this.directUrl}) + : architectures = ReFreezerLatest.abiMap[abi] ?? [abi]; } diff --git a/pubspec.yaml b/pubspec.yaml index a316d93..8438a9d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -65,6 +65,7 @@ dependencies: #flutter_file_dialog: ^3.0.1 flutter_inappwebview: ^6.0.0 flutter_local_notifications: ^17.0.0 + flutter_markdown: ^0.7.3 flutter_material_color_picker: ^1.2.0 flutter_screenutil: ^5.9.0 get_it: ^7.6.4 @@ -113,7 +114,7 @@ dependencies: rxdart: ^0.28.0 scrobblenaut: path: ./scrobblenaut - share_plus: ^9.0.0 + share_plus: ^10.0.0 spotify: ^0.13.1 sqflite: ^2.3.0 url_launcher: ^6.2.2