diff --git a/lib/internal/global.dart b/lib/internal/global.dart index b72dfe5..2c165e3 100644 --- a/lib/internal/global.dart +++ b/lib/internal/global.dart @@ -7,6 +7,7 @@ import 'package:flutter_pip/platform_channel/channel.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:songtube/internal/artwork_manager.dart'; +import 'package:songtube/internal/models/update/update_manger.dart'; import '../services/audio_service.dart'; @@ -26,7 +27,9 @@ Future initGlobals() async { androidNotificationChannelId: 'com.artxdev.songtube', androidNotificationChannelName: 'SongTube', )); - isPictureInPictureSupported = await FlutterPip.isPictureInPictureSupported() ?? false; + isPictureInPictureSupported = + await FlutterPip.isPictureInPictureSupported() ?? false; + AppUpdateManger.inAppUpdater(); } // App Custom Accent Color diff --git a/lib/internal/models/update/update_detail.dart b/lib/internal/models/update/update_detail.dart new file mode 100644 index 0000000..2eda7c1 --- /dev/null +++ b/lib/internal/models/update/update_detail.dart @@ -0,0 +1,78 @@ +class UpdateDetails { + String version; + double versionDouble; + String publishDate; + String updateDetails; + Uri armeabi; + Uri arm64; + Uri general; + Uri x86; + + UpdateDetails( + {required this.version, + this.versionDouble = 0, + required this.publishDate, + required this.updateDetails, + required this.arm64, + required this.armeabi, + required this.general, + required this.x86}); + + factory UpdateDetails.fromMap(dynamic map) { + return UpdateDetails( + version: _getRemoteVersion(map['name']), + versionDouble: _remoteVersionDouble(_getRemoteVersion(map['name'])), + publishDate: map["published_at"].split("T").first, + updateDetails: map["body"], + arm64: _getPlatformType(SupportedAbi.arm64, assets: map["assets"]), + armeabi: _getPlatformType(SupportedAbi.armeabi, assets: map["assets"]), + general: _getPlatformType(SupportedAbi.general, assets: map["assets"]), + x86: _getPlatformType(SupportedAbi.x86, assets: map["assets"])); + } + @override + String toString() { + return "{Version: $version, publishDate: $publishDate," + "\narm: $armeabi,\narm64: $arm64,\n" + "updateDetails: $updateDetails}"; + } +} + +/// Common android abi +enum SupportedAbi { + arm64, //arm64-v8a + armeabi, //armeabi-v7a + general, + x86, +} + +/// Parse apk url to various class fields +Uri _getPlatformType(SupportedAbi abi, {required List assets}) { + var url = ""; + + /// Special method to set general field since the url + /// doesn't contain any method to identify it. + if (SupportedAbi.general == abi) { + for (var i = 0; i < assets.length; i++) { + String? abiUrl = assets[i]["browser_download_url"]; + if (!(abiUrl!.contains(SupportedAbi.x86.name) | + abiUrl.contains(SupportedAbi.arm64.name) | + abiUrl.contains(SupportedAbi.armeabi.name))) { + url = abiUrl; + return Uri.parse(url); + } + } + } + for (var i = 0; i < assets.length; i++) { + String? abiUrl = assets[i]["browser_download_url"]; + if (abiUrl!.contains(abi.name)) { + url = abiUrl; + break; + } + } + return Uri.parse(url); +} + +String _getRemoteVersion(String version) => version.split(" ").last; + +double _remoteVersionDouble(String version) => + double.parse(version.split("+").first.replaceRange(3, 5, "")); diff --git a/lib/internal/models/update/update_manger.dart b/lib/internal/models/update/update_manger.dart new file mode 100644 index 0000000..a2cfccf --- /dev/null +++ b/lib/internal/models/update/update_manger.dart @@ -0,0 +1,131 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:android_path_provider/android_path_provider.dart'; +import 'package:apk_installer/apk_installer.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:songtube/internal/global.dart'; +import 'package:songtube/internal/models/update/update_detail.dart'; +import 'package:songtube/main.dart'; +import 'package:songtube/ui/components/app_update_dialog.dart'; + +class AppUpdateManger { + static final BehaviorSubject downloadProgress = + BehaviorSubject.seeded(.0); + + static double appVersion = 0; + static late PackageInfo packageInfo; + + /// Checks for app update + static void inAppUpdater() async { + packageInfo = await PackageInfo.fromPlatform(); + appVersion = double.parse(packageInfo.version.replaceRange(3, 5, "")); + final latestRelease = await _getLatestRelease(); + if (latestRelease != null && appVersion < latestRelease.versionDouble) { + showDialog( + barrierDismissible: false, + context: internalNavigatorKey.currentContext!, + builder: (context) { + return AppUpdateDialog( + details: latestRelease, + ); + }); + } + } + + /// Downloads update if it's available + static void download(UpdateDetails details) async { + Uri? downloadUrl = _abiToDownload(details); + //print("Downloading: $downloadUrl"); + var client = http.Client(); + int downloadedLength = 0; + final saveName = await _fileName(downloadUrl.toString()); + final ioSink = saveName.openWrite(mode: FileMode.writeOnly); + + final download = await client.send(http.Request("GET", downloadUrl)); + final contentLength = download.contentLength; + + download.stream.listen((value) { + downloadedLength += value.length; + downloadProgress.add(downloadedLength / contentLength!); + ioSink.add(value); + }, onDone: () async { + await ioSink.flush(); + await ioSink.close(); + await downloadProgress.close(); + client.close(); + _installApk(saveName.absolute.path); + }, onError: (e) async { + await ioSink.close(); + client.close(); + print("Error from download: $e"); + }); + } + + /// Check GitHub for new update. + static Future _getLatestRelease() async { + var client = http.Client(); + var headers = { + "Accept": "application/vnd.github.v3+json", + }; + const songTubeNew = + "https://api.github.com/repos/SongTube/SongTube-New/releases"; + var repoUrl = Uri.parse(songTubeNew); + try { + var response = await client.get(repoUrl, headers: headers); + if (response.body.isNotEmpty && response.body.trim() != "[]") { + var jsonResponse = jsonDecode(response.body); + UpdateDetails details = UpdateDetails.fromMap(jsonResponse[0]); + client.close(); + return details; + } + } catch (e, s) { + client.close(); + print("Error with getting update: $e\n$s"); + return null; + } + return null; + } + + /// Decides which apk to download based on device abi + static Uri _abiToDownload(UpdateDetails details) { + late Uri abi; + for (var element in deviceInfo.supportedAbis) { + if (element!.contains(SupportedAbi.armeabi.name)) { + abi = details.armeabi; + } else if (element.contains(SupportedAbi.arm64.name)) { + abi = details.arm64; + } else if (element.contains(SupportedAbi.x86.name)) { + abi = details.x86; + } else { + abi = details.general; + } + } + return abi; + } + + /// Installs the downloaded apk + static void _installApk(String apkPath) async { + await ApkInstaller.installApk(apkPath); + } + + /// Determine apk save name + static Future _fileName(String file) async { + final savePath = await _createDir(); + final fileName = File("$savePath/${file.split("/").last}"); + return fileName; + } + + /// Create a folder (SongTube) at download + static Future _createDir() async { + final path = + Directory("${await AndroidPathProvider.downloadsPath}/SongTube"); + if (!(await path.exists())) { + await path.create(); + } + return path.path; + } +} diff --git a/lib/ui/components/app_update_dialog.dart b/lib/ui/components/app_update_dialog.dart new file mode 100644 index 0000000..6468647 --- /dev/null +++ b/lib/ui/components/app_update_dialog.dart @@ -0,0 +1,190 @@ +import 'package:avatar_glow/avatar_glow.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +import '../../internal/models/update/update_detail.dart'; +import '../../internal/models/update/update_manger.dart'; + +class AppUpdateDialog extends StatefulWidget { + final UpdateDetails details; + const AppUpdateDialog({required this.details, super.key}); + + @override + State createState() => _AppUpdateDialogState(); +} + +class _AppUpdateDialogState extends State { + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Row( + children: [ + SizedBox( + height: 90, + width: 90, + child: AvatarGlow( + repeat: true, + endRadius: 45, + showTwoGlows: false, + glowColor: Theme.of(context).colorScheme.secondary, + repeatPauseDuration: const Duration(milliseconds: 50), + child: Image.asset( + 'assets/images/ic_launcher.png', + width: 70, + height: 70, + ), + ), + ), + const SizedBox(width: 4), + Text( + "SongTube", + style: TextStyle( + color: Theme.of(context).textTheme.bodyLarge?.color, + fontFamily: 'YTSans', + fontSize: 24), + ) + ], + ), + const SizedBox(height: 16), + Row( + children: [ + Text( + "New version available", + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyLarge + ?.color + ?.withOpacity(0.8), + fontSize: 16), + ), + const Spacer(), + Text( + widget.details.version, + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontSize: 16), + ) + ], + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: Text( + "What's new:", + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, fontSize: 16), + ), + ) + ], + ), + content: SingleChildScrollView( + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: MarkdownBody(data: widget.details.updateDetails)), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text( + "Later", + style: TextStyle( + color: Theme.of(context) + .textTheme + .bodyLarge + ?.color + ?.withOpacity(0.8), + fontFamily: 'YTSans', + fontSize: 16), + )), + TextButton( + onPressed: () { + Navigator.pop(context); + showDialog( + context: context, + builder: (context) { + AppUpdateManger.download(widget.details); + return const _AppUpdate(); + }, + ); + }, + style: TextButton.styleFrom( + fixedSize: const Size(100, 50), + backgroundColor: + Theme.of(context).colorScheme.secondary.withOpacity(0.5), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + )), + child: const Text( + "Update", + style: TextStyle( + color: Colors.white, fontFamily: 'YTSans', fontSize: 16), + ), + ), + ], + ); + } +} + +class _AppUpdate extends StatelessWidget { + const _AppUpdate({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text( + "Downloading", + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontFamily: "YTSans", + fontSize: 20), + ), + content: StreamBuilder( + stream: AppUpdateManger.downloadProgress.stream, + builder: (context, snapshot) { + final progress = snapshot.data ?? 0; + final percent = (progress * 100).round(); + + return Container( + padding: const EdgeInsets.all(8.0), + height: 45, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + "$percent%", + style: const TextStyle( + color: Colors.white, + fontFamily: 'YTSans', + fontSize: 16), + ), + ], + ), + const SizedBox( + height: 5, + ), + ClipRRect( + borderRadius: BorderRadius.circular(10), + child: LinearProgressIndicator( + backgroundColor: Theme.of(context).cardColor, + value: progress, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.secondary), + ), + ), + ], + ), + ); + }), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1946672..1c358c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -133,6 +133,22 @@ dependencies: git: url: https://github.com/SongTube/NewPipeExtractor_Dart ref: master + # Pip + flutter_pip: + git: + url: https://github.com/Artx-II/flutter_pip.git + ref: main + + # Apk installer + apk_installer: + git: + url: https://github.com/SongTube/apk_installer.git + ref: main + + # For InApp update + package_info_plus: ^3.1.2 + flutter_markdown: ^0.6.14 + avatar_glow: ^2.0.2 # FFmpeg flutter_ffmpeg: