From b8f0bb214089454cc737f3abb88cff3b8b10d6aa Mon Sep 17 00:00:00 2001 From: Pato05 Date: Mon, 12 Feb 2024 03:37:26 +0100 Subject: [PATCH] add 'login on other device' --- .../deezer_audio_source.dart | 52 ++-- .../offline_audio_source.dart | 0 .../{ => audio_sources}/url_audio_source.dart | 5 +- lib/api/deezer.dart | 2 +- lib/api/deezer_audio.dart | 23 +- lib/api/download.dart | 17 +- .../download_manager/download_manager.dart | 6 +- .../download_manager/download_service.dart | 27 ++ lib/api/player/audio_handler.dart | 24 +- lib/settings.dart | 9 + lib/ui/login_on_other_device.dart | 249 ++++++++++++++++++ lib/ui/login_screen.dart | 206 ++++++++++++--- lib/ui/search.dart | 2 +- lib/ui/settings_screen.dart | 10 +- lib/utils.dart | 30 +++ pubspec.lock | 113 ++++---- 16 files changed, 642 insertions(+), 133 deletions(-) rename lib/api/{ => audio_sources}/deezer_audio_source.dart (74%) rename lib/api/{ => audio_sources}/offline_audio_source.dart (100%) rename lib/api/{ => audio_sources}/url_audio_source.dart (90%) create mode 100644 lib/ui/login_on_other_device.dart create mode 100644 lib/utils.dart diff --git a/lib/api/deezer_audio_source.dart b/lib/api/audio_sources/deezer_audio_source.dart similarity index 74% rename from lib/api/deezer_audio_source.dart rename to lib/api/audio_sources/deezer_audio_source.dart index 8106154..ec0ac63 100644 --- a/lib/api/deezer_audio_source.dart +++ b/lib/api/audio_sources/deezer_audio_source.dart @@ -24,10 +24,10 @@ class DeezerAudioSource extends StreamAudioSource { late AudioQuality Function() _getQuality; late String _trackId; - late String _md5origin; - late String _mediaVersion; - final String trackToken; - final int trackTokenExpiration; + late String? _md5origin; + late String? _mediaVersion; + String? _trackToken; + int? _trackTokenExpiration; final StreamInfoCallback? onStreamObtained; // some cache @@ -39,28 +39,28 @@ class DeezerAudioSource extends StreamAudioSource { DeezerAudioSource({ required AudioQuality Function() getQuality, required String trackId, - required String md5origin, - required String mediaVersion, - required this.trackToken, - required this.trackTokenExpiration, + String? md5origin, + String? mediaVersion, + String? trackToken, + int? trackTokenExpiration, this.onStreamObtained, }) : _deezerAudio = DeezerAudio( deezerAPI: deezerAPI, - md5origin: md5origin, quality: getQuality.call(), trackId: trackId, - mediaVersion: mediaVersion, ) { _getQuality = getQuality; _trackId = trackId; _md5origin = md5origin; _mediaVersion = mediaVersion; + _trackToken = trackToken; + _trackTokenExpiration = trackTokenExpiration; } AudioQuality? get quality => _deezerAudio.quality; String get trackId => _trackId; - String get md5origin => _md5origin; - String get mediaVersion => _mediaVersion; + String? get md5origin => _md5origin; + String? get mediaVersion => _mediaVersion; @override Future request([int? start, int? end]) async { @@ -94,16 +94,32 @@ class DeezerAudioSource extends StreamAudioSource { _deezerAudio.quality = newQuality; if (_downloadUrl == null) { - final gottenUrl = - await _deezerAudio.getUrl(trackToken, trackTokenExpiration); - if (gottenUrl != null) { - _downloadUrl = gottenUrl; - } else { + if (_trackToken == null) { + // TODO: get new track token? + final track = await deezerAPI.track(trackId); + _trackToken = track.trackToken; + _trackTokenExpiration = track.trackTokenExpiration; + _mediaVersion = track.playbackDetails![1]; + _md5origin = track.playbackDetails![0]; + } + try { + _downloadUrl = + await _deezerAudio.getUrl(_trackToken!, _trackTokenExpiration!); + } catch (e) { + _logger.warning('get_url API failed with error: $e'); _logger.warning('falling back to old url generation!'); + if (_md5origin == null || _mediaVersion == null) { + throw Exception( + 'Can\'t use old URL API: md5origin and mediaVersion are missing!'); + } // OLD URL GENERATION try { - _downloadUrl = await _deezerAudio.fallback(); + final res = await _deezerAudio.fallback( + md5origin: _md5origin!, mediaVersion: _mediaVersion!); + _downloadUrl = res.uri; + _md5origin = res.md5origin; + _mediaVersion = res.mediaVersion; } on QualityException { rethrow; } diff --git a/lib/api/offline_audio_source.dart b/lib/api/audio_sources/offline_audio_source.dart similarity index 100% rename from lib/api/offline_audio_source.dart rename to lib/api/audio_sources/offline_audio_source.dart diff --git a/lib/api/url_audio_source.dart b/lib/api/audio_sources/url_audio_source.dart similarity index 90% rename from lib/api/url_audio_source.dart rename to lib/api/audio_sources/url_audio_source.dart index 4f9776f..c4e7569 100644 --- a/lib/api/url_audio_source.dart +++ b/lib/api/audio_sources/url_audio_source.dart @@ -1,9 +1,8 @@ +import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/definitions.dart'; import 'package:just_audio/just_audio.dart'; import 'package:http/http.dart' as http; -import 'deezer.dart'; - class UrlAudioSource extends StreamAudioSource { final Uri uri; final StreamInfoCallback? onStreamObtained; @@ -13,7 +12,7 @@ class UrlAudioSource extends StreamAudioSource { Future request([int? start, int? end]) async { final req = http.Request('GET', uri) ..headers.addAll({ - 'User-Agent': deezerAPI.headers['User-Agent']!, + 'User-Agent': DeezerAPI.userAgent, 'Accept-Language': '*', 'Accept': '*/*', if (start != null || end != null) diff --git a/lib/api/deezer.dart b/lib/api/deezer.dart index b9e94f5..74d0816 100644 --- a/lib/api/deezer.dart +++ b/lib/api/deezer.dart @@ -383,7 +383,7 @@ class DeezerAPI { return SearchResults.fromPrivateJson(data['results']); } - Future track(String? id) async { + Future track(String id) async { Map data = await callApi('song.getListData', params: { 'sng_ids': [id] }); diff --git a/lib/api/deezer_audio.dart b/lib/api/deezer_audio.dart index facee3a..1cf4278 100644 --- a/lib/api/deezer_audio.dart +++ b/lib/api/deezer_audio.dart @@ -14,32 +14,31 @@ class DeezerAudio { static const chunkSize = 2048; final DeezerAPI deezerAPI; - String md5origin; AudioQuality quality; String trackId; - String mediaVersion; DeezerAudio({ required this.deezerAPI, - required this.md5origin, required this.quality, required this.trackId, - required this.mediaVersion, }); static final _logger = Logger('DeezerAudio'); - Future fallback() async { + Future<({Uri uri, String md5origin, String mediaVersion})> fallback( + {required String md5origin, required String mediaVersion}) async { final res = await fallbackUrl( md5origin: md5origin, preferredQuality: quality, trackId: trackId, mediaVersion: mediaVersion); - md5origin = res.md5origin; quality = res.quality; trackId = res.trackId; - mediaVersion = res.mediaVersion; - return res.uri; + return ( + uri: res.uri, + md5origin: res.md5origin, + mediaVersion: res.mediaVersion + ); } Future< @@ -281,6 +280,9 @@ class DeezerAudio { } } + static bool isTokenExpired(int trackTokenExpiration) => + DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration; + Future getUrl(String trackToken, int expiration) => getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality); @@ -292,10 +294,7 @@ class DeezerAudio { required AudioQuality quality, }) async { final String actualTrackToken; - if (DateTime.now().millisecondsSinceEpoch ~/ 1000 > expiration) { - if (!deezerAPI.canStreamHQ && !deezerAPI.canStreamLossless) { - _logger.fine('using old url generation, token expired.'); - } + if (isTokenExpired(expiration)) { final newTrack = await deezerAPI.track(trackId); actualTrackToken = newTrack.trackToken!; } else { diff --git a/lib/api/download.dart b/lib/api/download.dart index 9f94514..0f35eca 100644 --- a/lib/api/download.dart +++ b/lib/api/download.dart @@ -161,7 +161,7 @@ class DownloadManager { bool isSingleton = false}) async { if (!isSupported) return false; //Permission - if (!private && !(await checkPermission())) return false; + if (!private && !(await checkCanDownload())) return false; //Ask for quality AudioQuality? quality; @@ -201,7 +201,7 @@ class DownloadManager { Future addOfflineAlbum(Album? album, {private = true, BuildContext? context}) async { //Permission - if (!private && !(await checkPermission())) return; + if (!private && !(await checkCanDownload())) return; //Ask for quality AudioQuality? quality; @@ -245,7 +245,7 @@ class DownloadManager { if (!isSupported) return false; //Permission - if (!private && !(await checkPermission())) return; + if (!private && !(await checkCanDownload())) return; //Ask for quality if (!private && @@ -629,15 +629,20 @@ class DownloadManager { 'updateSettings', settings.getServiceSettings()); } - //Check storage permission - Future checkPermission() async { + static Future checkCanDownload() { if (settings.downloadPath == null) { Fluttertoast.showToast( msg: 'Set download path in settings first!'.i18n, toastLength: Toast.LENGTH_LONG, gravity: ToastGravity.BOTTOM); - return false; + return Future.value(false); } + + return checkPermission(); + } + + //Check storage permission + static Future checkPermission() async { if (await Permission.storage.request().isGranted) { return true; } else if ( // android 12 or later diff --git a/lib/api/download_manager/download_manager.dart b/lib/api/download_manager/download_manager.dart index 9671f13..95c7fad 100644 --- a/lib/api/download_manager/download_manager.dart +++ b/lib/api/download_manager/download_manager.dart @@ -86,7 +86,9 @@ class DownloadManager { Future addOfflineTrack(d.Track track, {bool private = true, BuildContext? context, isSingleton = false}) async { //Permission - //if (!private && !(await checkPermission())) return false; + if (!private && !(await dl.DownloadManager.checkCanDownload())) { + return false; + } //Ask for quality //AudioQuality? quality; @@ -127,6 +129,6 @@ class DownloadManager { static void _startNative(ServiceInstance service) => _startService(ServiceInterface(service: service)); - static void _startService(ServiceInterface? service) => + static void _startService(ServiceInterface service) => DownloadService(service).run(); } diff --git a/lib/api/download_manager/download_service.dart b/lib/api/download_manager/download_service.dart index 891312b..cfb3c7c 100644 --- a/lib/api/download_manager/download_service.dart +++ b/lib/api/download_manager/download_service.dart @@ -1,4 +1,8 @@ +import 'package:freezer/api/cache.dart'; +import 'package:freezer/api/deezer_audio.dart'; +import 'package:freezer/api/download_manager/database.dart'; import 'package:freezer/api/download_manager/service_interface.dart'; +import 'package:freezer/settings.dart'; class DownloadService { static const NOTIFICATION_ID = 6969; @@ -7,7 +11,30 @@ class DownloadService { final ServiceInterface service; DownloadService(this.service); + AudioQuality? _preferredQuality; + AudioQuality? _downloadQuality; + bool useGetURL = false; + void run() { service.on('addDownloads').listen((event) {}); + service.on('updateQuality').listen((event) { + _preferredQuality = AudioQuality.values[event!['q']!]; + }); + service.on('updateCapabilities').listen((event) { + final bool canStreamHQ = event!['canStreamHQ']; + final bool canStreamLossless = event['canStreamLossless']; + + if (canStreamHQ || canStreamLossless) useGetURL = true; + + _downloadQuality = settings.maxQualityFor( + _preferredQuality!, canStreamHQ, canStreamLossless); + }); + } + + void downloadTrack(String trackId) { + // final deezerAudio = DeezerAudio(deezerAPI: deezerAPI, md5origin: md5origin, quality: quality, trackId: trackId, mediaVersion: mediaVersion) + // if (useGetURL) { + // final url = + // } } } diff --git a/lib/api/player/audio_handler.dart b/lib/api/player/audio_handler.dart index d7d5940..8c70d3f 100644 --- a/lib/api/player/audio_handler.dart +++ b/lib/api/player/audio_handler.dart @@ -3,11 +3,11 @@ import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart'; import 'package:freezer/api/cache.dart'; import 'package:freezer/api/deezer.dart'; -import 'package:freezer/api/deezer_audio_source.dart'; -import 'package:freezer/api/offline_audio_source.dart'; +import 'package:freezer/api/audio_sources/deezer_audio_source.dart'; +import 'package:freezer/api/audio_sources/offline_audio_source.dart'; import 'package:freezer/api/paths.dart'; import 'package:freezer/api/player/player_helper.dart'; -import 'package:freezer/api/url_audio_source.dart'; +import 'package:freezer/api/audio_sources/url_audio_source.dart'; import 'package:freezer/ui/android_auto.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:just_audio/just_audio.dart'; @@ -153,6 +153,7 @@ class AudioPlayerTask extends BaseAudioHandler { // Linux/Windows specific options JustAudioMediaKit.title = 'Freezer'; JustAudioMediaKit.protocolWhitelist = const ['http']; + //JustAudioMediaKit.bufferSize = 128; _deezerAPI = initArgs.deezerAPI; _androidAuto = AndroidAuto(deezerAPI: _deezerAPI); @@ -624,9 +625,10 @@ class AudioPlayerTask extends BaseAudioHandler { //This just returns fake url that contains metadata List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']); - if ((playbackDetails ?? []).length < 2) { - throw Exception('not enough playback details'); - } + // DON'T CARE, WE DON'T NEED THOSE WITH NEW API + // if ((playbackDetails ?? []).length < 2) { + // throw Exception('not enough playback details'); + // } //String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}'; // final uri = Uri.http('localhost:36958', '', { @@ -642,10 +644,12 @@ class AudioPlayerTask extends BaseAudioHandler { return DeezerAudioSource( getQuality: () => _currentQuality, trackId: mediaItem.id, - trackToken: mediaItem.extras!['trackToken'] ?? '', - trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'] ?? 0, - md5origin: playbackDetails![0], - mediaVersion: playbackDetails[1], + // THESE NEXT 4 CAN BE NULL. + // IF THEY ARE NULL, THEY'RE GONNA BE FETCHED LATER ON. + trackToken: mediaItem.extras!['trackToken'], + trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'], + md5origin: playbackDetails?[0], + mediaVersion: playbackDetails?[1], onStreamObtained: (qualityInfo) => customEvent.add({'action': 'streamInfo', 'data': qualityInfo}), ); diff --git a/lib/settings.dart b/lib/settings.dart index 446424f..c91951c 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -181,6 +181,15 @@ class Settings { Settings(); + AudioQuality maxQualityFor( + AudioQuality quality, bool canStreamHQ, bool canStreamLossless) { + if (canStreamLossless) return quality; + final maxQuality = + canStreamHQ ? AudioQuality.MP3_320 : AudioQuality.MP3_128; + + return _minQuality(quality, maxQuality); + } + void checkQuality(bool canStreamHQ, bool canStreamLossless) { if (canStreamLossless) return; diff --git a/lib/ui/login_on_other_device.dart b/lib/ui/login_on_other_device.dart new file mode 100644 index 0000000..321c409 --- /dev/null +++ b/lib/ui/login_on_other_device.dart @@ -0,0 +1,249 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:freezer/api/definitions.dart'; +import 'package:freezer/settings.dart'; +import 'package:freezer/utils.dart'; +import 'package:pointycastle/export.dart' as pc; +import 'package:pointycastle/src/platform_check/platform_check.dart'; +import 'package:encrypt/encrypt.dart' as encrypt; +import 'package:freezer/translations.i18n.dart'; + +class LoginOnOtherDevice extends StatefulWidget { + const LoginOnOtherDevice({super.key}); + + @override + State createState() => _LoginOnOtherDeviceState(); +} + +class _LoginOnOtherDeviceState extends State { + String _ipAddress = ''; + String _code = ''; + String? _error; + bool _loading = false; + bool _step2 = false; + final GlobalKey _formKey = GlobalKey(); + + late final encrypt.Key key; + + void _doHandshake() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _loading = true; + _error = null; + }); + + // generate keypair + final keyGen = pc.RSAKeyGenerator(); + final secureRandom = pc.SecureRandom('Fortuna') + ..seed(pc.KeyParameter( + Platform.instance.platformEntropySource().getBytes(32))); + + keyGen.init(pc.ParametersWithRandom( + pc.RSAKeyGeneratorParameters(BigInt.parse('65537'), 2048, 64), + secureRandom)); + + final keyPair = keyGen.generateKeyPair(); + final privKey = keyPair.privateKey as pc.RSAPrivateKey; + final pubKey = keyPair.publicKey as pc.RSAPublicKey; + + // initial handshake + final hash = Hmac(sha512, Utils.splitDigits(int.parse(_code))) + .convert(Utils.serializeBigInt(pubKey.exponent! ^ pubKey.modulus!)) + .toString(); + late final Response res; + try { + res = await Dio().post('http://$_ipAddress/', + data: jsonEncode({ + '_': 'handshake', + 'pubKey': [ + base64.encode(Utils.serializeBigInt(pubKey.modulus!)), + base64.encode(Utils.serializeBigInt(pubKey.exponent!)) + ], + 'hash': hash, + }), + options: Options(responseType: ResponseType.json)); + } on DioException catch (e) { + if (e.type == DioExceptionType.badResponse) { + if (e.response?.statusCode == 403) { + setState(() { + _error = 'Wrong code'; + _loading = false; + }); + return; + } + final data = e.response?.data as Map?; + if (data != null && data['message'] is String) { + setState(() { + _error = 'Request failed: ${data['message']}'; + _loading = false; + }); + + return; + } + setState(() { + _error = 'Request failed: $e'; + _loading = false; + }); + + return; + } + } + + print(res); + final data = res.data as Map; + if (!data['ok']) { + setState(() { + _error = data['message'] as String? ?? 'Unknown server error'; + _loading = false; + }); + + return; + } + + final encryptedKey = data['key'] as String; + final encrypter = encrypt.Encrypter(encrypt.RSA(privateKey: privKey)); + key = encrypt.Key(Uint8List.fromList( + encrypter.decryptBytes(encrypt.Encrypted.fromBase64(encryptedKey)))); + + setState(() { + _loading = false; + _step2 = true; + }); + } + + void _loginUsingArl(String arl) async { + setState(() { + _loading = true; + }); + + final encrypter = encrypt.Encrypter( + encrypt.AES(key), + ); + final iv = encrypt.IV.fromSecureRandom(16); + final encryptedARL = encrypter.encrypt(arl, iv: iv); + + // send it over with dio + late final Response res; + try { + res = await Dio().post('http://$_ipAddress/', + data: jsonEncode({ + '_': 'arl', + 'arl': encryptedARL.base64, + 'iv': iv.base64, + }), + options: Options(responseType: ResponseType.json)); + } on DioException catch (e) { + if (e.type == DioExceptionType.badResponse) { + final data = e.response?.data as Map?; + if (data != null && data['message'] is String) { + setState(() { + _error = 'Request failed: ${data['message']}'; + _loading = false; + }); + } + } + setState(() { + _error = 'Request failed: $e'; + _loading = false; + }); + return; + } + + if (!res.data['ok']) { + setState(() { + _error = res.data['message'] as String? ?? 'Unknown server error.'; + _loading = false; + }); + return; + } + + Navigator.pop(context); + ScaffoldMessenger.of(context).snack('Logged in successfully!'.i18n); + } + + void _cancel() async { + if (_step2) { + final hash = + Hmac(sha512, key.bytes).convert(utf8.encode(_code)).toString(); + await Dio().post('http://$_ipAddress/', + data: jsonEncode({'_': 'cancel', 'hash': hash})); + } + + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Login on other device'.i18n), + content: _step2 + ? Column(mainAxisSize: MainAxisSize.min, children: [ + if (_error != null) + Text(_error!, style: const TextStyle(color: Colors.red)), + OutlinedButton( + onPressed: + _loading ? null : () => _loginUsingArl(settings.arl!), + child: Text('Login with current ARL'.i18n)), + ]) + : Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_error != null) + Text(_error!, style: const TextStyle(color: Colors.red)), + TextFormField( + validator: (value) { + value ??= ''; + final p = value.split(':'); + if (p.length != 2) return 'Invalid IP and Port'; + final ip = p[0]; + final port = int.tryParse(p[1]); + if (port == null || port > 65535) return 'Invalid port'; + final ipParts = ip.split('.'); + if (ipParts.length != 4) return 'Invalid IP'; + for (final part in ipParts) { + final a = int.tryParse(part); + if (a == null || a < 0 || a > 255) { + return 'Invalid IP'; + } + } + + return null; + }, + onChanged: (value) => _ipAddress = value, + decoration: + InputDecoration(label: Text('IP Address'.i18n)), + ), + TextFormField( + validator: (value) { + value ??= ''; + if (value.length != 6 || int.tryParse(value) == null) { + return 'Invalid code'; + } + + return null; + }, + onChanged: (value) => _code = value, + decoration: InputDecoration(label: Text('Code'.i18n)), + ) + ]), + ), + actions: [ + if (!_step2) + TextButton( + onPressed: _loading ? null : _doHandshake, + child: Text('Login'.i18n), + ), + TextButton(onPressed: _cancel, child: Text('Cancel'.i18n)) + ], + ); + } +} diff --git a/lib/ui/login_screen.dart b/lib/ui/login_screen.dart index 770db7e..d4f7369 100644 --- a/lib/ui/login_screen.dart +++ b/lib/ui/login_screen.dart @@ -4,11 +4,16 @@ import 'dart:io'; import 'dart:math'; import 'package:async/async.dart'; +import 'package:crypto/crypto.dart'; +import 'package:encrypt/encrypt.dart' as encrypt; +import 'package:encrypt/encrypt_io.dart'; +import 'package:pointycastle/asymmetric/api.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:freezer/api/deezer.dart'; import 'package:freezer/translations.i18n.dart'; +import 'package:freezer/utils.dart'; import 'package:logging/logging.dart'; import 'package:network_info_plus/network_info_plus.dart'; import 'package:rxdart/rxdart.dart'; @@ -216,7 +221,7 @@ class _LoginWidgetState extends State { textAlign: TextAlign.center, style: const TextStyle(fontSize: 16.0), ), - const SizedBox(height: 16.0), + const SizedBox(height: 32.0), ElevatedButton( child: Text('Login using other device'.i18n), @@ -242,12 +247,13 @@ class _LoginWidgetState extends State { // const SizedBox(height: 2.0), // only supported on android - if (Platform.isAndroid) + if (Platform.isAndroid) ...[ ElevatedButton( onPressed: _loginBrowser, child: Text('Login using browser'.i18n), ), - const SizedBox(height: 2.0), + const SizedBox(height: 2.0), + ], ElevatedButton( child: Text('Login using token'.i18n), onPressed: () { @@ -441,16 +447,14 @@ class _OtherDeviceLoginState extends State { late int _code; late Timer _codeTimer; final _timerNotifier = ValueNotifier(0.0); - bool step2 = false; + bool _step2 = false; final _logger = Logger('OtherDeviceLogin'); void _generateCode() { _code = Random.secure().nextInt(899999) + 100000; } - Future _initServer() async { - _server = await HttpServer.bind(InternetAddress.anyIPv4, 0); - _deviceIP = await NetworkInfo().getWifiIP(); + void _initTimer() { _generateCode(); const tps = 30 * 1000 / 50; _codeTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) { @@ -460,14 +464,30 @@ class _OtherDeviceLoginState extends State { setState(() => _generateCode()); } }); + } + + Future _initServer() async { + _server = await HttpServer.bind(InternetAddress.anyIPv4, 0); + _deviceIP = await NetworkInfo().getWifiIP(); + _initTimer(); + + encrypt.Key? key; + + void invalidRequest(HttpRequest request) { + request.response.statusCode = 400; + request.response.close(); + } + _serverSubscription = _server.listen((request) async { - final buffer = Uint8List(0); + final buffer = []; final reqCompleter = Completer(); final subs = request.listen( (data) { if (data.length + buffer.length > 8192) { _logger.severe('Request too big!'); request.response.close(); + reqCompleter.completeError("Request too big!"); + return; } buffer.addAll(data); @@ -476,13 +496,129 @@ class _OtherDeviceLoginState extends State { reqCompleter.complete(); }, ); - await reqCompleter.future; - subs.cancel(); try { - jsonDecode(utf8.decode(buffer)); + await reqCompleter.future; + } catch (e) { + return; + } + subs.cancel(); + late final Map data; + try { + data = jsonDecode(utf8.decode(buffer)); } catch (e) { _logger.severe('Error $e'); - request.response.close(); + return invalidRequest(request); + } + + // check if data is correct + if (!data.containsKey('_') || data['_'] is! String) { + return invalidRequest(request); + } + + print(data); + switch (data['_']) { + case 'handshake': + if (key != null) { + request.response.statusCode = 400; + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode({ + 'ok': false, + 'message': 'Another client has already done the handshake.' + })); + request.response.close(); + return; + } + if (!data.containsKey('pubKey') || + data['pubKey'] is! List || + data['pubKey'].length != 2 || + !data.containsKey('hash') || + data['hash'] is! String) { + return invalidRequest(request); + } + + final pubKey = RSAPublicKey( + Utils.deserializeBigInt(base64.decode(data['pubKey'][0])), + Utils.deserializeBigInt(base64.decode(data['pubKey'][1]))); + final hash = Hmac(sha512, Utils.splitDigits(_code)) + .convert( + Utils.serializeBigInt(pubKey.exponent! ^ pubKey.modulus!)) + .toString(); + if (hash != data['hash']) { + request.response.statusCode = 403; + request.response.write( + jsonEncode({'ok': false, 'message': 'Hash doesn\'t match'})); + request.response.close(); + return; + } + + final encrypter = encrypt.Encrypter(encrypt.RSA(publicKey: pubKey)); + + key = encrypt.Key.fromSecureRandom(32); + final encryptedKey = encrypter.encryptBytes(key!.bytes); + + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode({ + 'ok': true, + 'key': encryptedKey.base64, + })); + request.response.close(); + _codeTimer.cancel(); + setState(() => _step2 = true); + break; + case 'arl': + if (!data.containsKey('arl') || + data['arl'] is! String || + !data.containsKey('iv') || + data['iv'] is! String) { + return invalidRequest(request); + } + + final encryptedArl = data['arl'] as String; + final iv = data['iv'] as String; + final encrypter = encrypt.Encrypter(encrypt.AES(key!)); + final decryptedArl = encrypter.decrypt( + encrypt.Encrypted.fromBase64(encryptedArl), + iv: encrypt.IV.fromBase64(iv)); + if (decryptedArl.length != 192) { + request.response.headers.contentType = ContentType.json; + request.response.write( + jsonEncode({'ok': false, 'message': 'Wrong ARL length!'})); + request.response.close(); + return; + } + + request.response.headers.contentType = ContentType.json; + request.response.write(jsonEncode({'ok': true})); + request.response.close(); + + settings.arl = decryptedArl; + Navigator.pop(context); + widget.callback(); + break; + case 'cancel': + if (!data.containsKey('hash') || data['hash'] is! String) { + return invalidRequest(request); + } + + final hash = Hmac(sha512, key!.bytes) + .convert(utf8.encode(_code.toString())) + .toString(); + if (hash != data['hash']) { + request.response.statusCode = 403; + request.response.write( + jsonEncode({'ok': false, 'message': 'Hash doesn\'t match'})); + request.response.close(); + return; + } + + key = null; + _initTimer(); + setState(() { + _step2 = false; + }); + + request.response.close(); + break; } }); } @@ -506,9 +642,18 @@ class _OtherDeviceLoginState extends State { return AlertDialog( title: Text('Login using other device'.i18n), contentPadding: const EdgeInsets.only(top: 12), - content: step2 - ? Text('Please follow the on-screen instructions'.i18n, - style: Theme.of(context).textTheme.bodyLarge) + content: _step2 + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const LinearProgressIndicator(), + Padding( + padding: const EdgeInsets.fromLTRB(24, 18, 24, 24), + child: Text('Please follow the on-screen instructions'.i18n, + style: Theme.of(context).textTheme.bodyLarge), + ), + ]) : FutureBuilder( future: _serverReady, builder: (context, snapshot) { @@ -523,7 +668,7 @@ class _OtherDeviceLoginState extends State { ValueListenableBuilder( valueListenable: _timerNotifier, builder: (context, value, _) => - LinearProgressIndicator(value: value), + LinearProgressIndicator(value: 1.0 - value), ), Padding( padding: const EdgeInsets.fromLTRB(24, 18, 24, 24), @@ -532,24 +677,23 @@ class _OtherDeviceLoginState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'On your other device, go to Freezer\'s settings > General > Login on other device, input the parameters below and follow the on-screen instructions.' - .i18n), + 'On your other device, go to Freezer\'s settings > General > Login on other device and input the parameters below' + .i18n, + style: Theme.of(context).textTheme.bodyLarge, + ), RichText( - textAlign: TextAlign.start, text: TextSpan( style: Theme.of(context).textTheme.bodyMedium, children: [ - TextSpan(text: 'IP Address: '.i18n), - TextSpan( - text: - _deviceIP ?? 'Could not get IP!', - style: TextStyle(fontSize: 32.sp)), - const TextSpan(text: ':'), - TextSpan( - text: _server.port.toString(), - style: TextStyle(fontSize: 32.sp)), - ])), + TextSpan(text: 'IP Address: '.i18n), + TextSpan( + text: + '${_deviceIP ?? 'Could not get IP!'}:${_server.port}', + style: Theme.of(context) + .textTheme + .displaySmall), + ])), RichText( text: TextSpan( style: @@ -558,7 +702,9 @@ class _OtherDeviceLoginState extends State { TextSpan(text: 'Code: '.i18n), TextSpan( text: '$_code', - style: TextStyle(fontSize: 32.sp)), + style: Theme.of(context) + .textTheme + .displaySmall), ])), ], ), diff --git a/lib/ui/search.dart b/lib/ui/search.dart index 75651f6..bca4c6b 100644 --- a/lib/ui/search.dart +++ b/lib/ui/search.dart @@ -28,7 +28,7 @@ FutureOr openScreenByURL(BuildContext context, String url) async { switch (res.type) { case DeezerLinkType.TRACK: - Track t = await deezerAPI.track(res.id); + Track t = await deezerAPI.track(res.id!); MenuSheet(context).defaultTrackMenu(t); break; case DeezerLinkType.ALBUM: diff --git a/lib/ui/settings_screen.dart b/lib/ui/settings_screen.dart index 84fbc07..8f2cd6b 100644 --- a/lib/ui/settings_screen.dart +++ b/lib/ui/settings_screen.dart @@ -11,6 +11,7 @@ import 'package:fluttericon/font_awesome5_icons.dart'; import 'package:fluttericon/web_symbols_icons.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:freezer/api/definitions.dart'; +import 'package:freezer/ui/login_on_other_device.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:scrobblenaut/scrobblenaut.dart'; @@ -1051,7 +1052,7 @@ class _DownloadsSettingsState extends State { settings.downloadPath ?? 'Not set, click here to set!'.i18n), onTap: () async { //Check permissions - if (!await downloadManager.checkPermission()) { + if (!await DownloadManager.checkPermission()) { return; } DownloadManager.getDirectory('Pick-a-Path'.i18n).then((path) { @@ -1448,6 +1449,13 @@ class _GeneralSettingsState extends State { setState(() {}); }, ), + ListTile( + title: Text('Login on other device'.i18n), + leading: const Icon(Icons.send_to_mobile), + onTap: () => showDialog( + context: context, + builder: (context) => const LoginOnOtherDevice()), + ), ListTile( title: Text( 'Log out'.i18n, diff --git a/lib/utils.dart b/lib/utils.dart new file mode 100644 index 0000000..81c9501 --- /dev/null +++ b/lib/utils.dart @@ -0,0 +1,30 @@ +import 'dart:typed_data'; + +class Utils { + static List splitDigits(int number) { + final digits = []; + while (number != 0) { + digits.add(number % 10); + number = number ~/ 10; + } + + return digits; + } + + static Uint8List serializeBigInt(BigInt bi) { + Uint8List array = Uint8List((bi.bitLength / 8).ceil()); + for (int i = 0; i < array.length; i++) { + array[i] = (bi >> (i * 8)).toUnsigned(8).toInt(); + } + return array; + } + + static BigInt deserializeBigInt(Uint8List array) { + var bi = BigInt.zero; + for (var byte in array.reversed) { + bi <<= 8; + bi |= BigInt.from(byte); + } + return bi; + } +} diff --git a/pubspec.lock b/pubspec.lock index ca6b45b..ddd0565 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -221,10 +221,10 @@ packages: dependency: "direct main" description: name: collection - sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted - version: "1.17.2" + version: "1.18.0" connectivity_plus: dependency: "direct main" description: @@ -362,15 +362,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.3" - equalizer: - dependency: "direct main" - description: - path: "." - ref: HEAD - resolved-ref: "84c15ca304a8129a1cad5a6891059fb411f0fc55" - url: "https://github.com/gladson97/equalizer.git" - source: git - version: "0.0.2+2" equatable: dependency: transitive description: @@ -505,22 +496,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" - flutter_inappwebview: - dependency: "direct main" - description: - name: flutter_inappwebview - sha256: d198297060d116b94048301ee6749cd2e7d03c1f2689783f52d210a6b7aba350 - url: "https://pub.dev" - source: hosted - version: "5.8.0" - flutter_isolate: - dependency: "direct main" - description: - name: flutter_isolate - sha256: "994ddec596da4ca12ca52154fd59404077584643eb7e3f1008a55fda9fe0b76b" - url: "https://pub.dev" - source: hosted - version: "2.0.4" flutter_lints: dependency: "direct dev" description: @@ -901,10 +876,10 @@ packages: dependency: transitive description: name: meta - sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" mime: dependency: transitive description: @@ -1085,42 +1060,50 @@ packages: dependency: "direct main" description: name: permission_handler - sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 + sha256: "3c84d49f0a5e1915364707159ab71f11b3b8a429532176d3a6248a45718ad4f9" url: "https://pub.dev" source: hosted - version: "10.4.5" + version: "11.2.1" permission_handler_android: dependency: transitive description: name: permission_handler_android - sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" + sha256: a5ebaa420cee8fd880ef10dedd42c6b3f493e7dbe27d7e0a7e1798669373082a url: "https://pub.dev" source: hosted - version: "10.3.6" + version: "12.0.4" permission_handler_apple: dependency: transitive description: name: permission_handler_apple - sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" + sha256: "6ca25ee52518a8a26e80aaefe3c71caf6e2dfd809c1b20900d0882df6faed36e" url: "https://pub.dev" source: hosted - version: "9.1.4" + version: "9.3.1" + permission_handler_html: + dependency: transitive + description: + name: permission_handler_html + sha256: "54bf176b90f6eddd4ece307e2c06cf977fb3973719c35a93b85cc7093eb6070d" + url: "https://pub.dev" + source: hosted + version: "0.1.1" permission_handler_platform_interface: dependency: transitive description: name: permission_handler_platform_interface - sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4" + sha256: "5c43148f2bfb6d14c5a8162c0a712afe891f2d847f35fcff29c406b37da43c3c" url: "https://pub.dev" source: hosted - version: "3.12.0" + version: "4.1.0" permission_handler_windows: dependency: transitive description: name: permission_handler_windows - sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 + sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e" url: "https://pub.dev" source: hosted - version: "0.1.3" + version: "0.2.1" petitparser: dependency: transitive description: @@ -1355,18 +1338,18 @@ packages: dependency: transitive description: name: stack_trace - sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" url: "https://pub.dev" source: hosted - version: "1.11.0" + version: "1.11.1" stream_channel: dependency: transitive description: name: stream_channel - sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 url: "https://pub.dev" source: hosted - version: "2.1.1" + version: "2.1.2" stream_transform: dependency: transitive description: @@ -1403,10 +1386,10 @@ packages: dependency: transitive description: name: test_api - sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" url: "https://pub.dev" source: hosted - version: "0.6.0" + version: "0.6.1" time: dependency: transitive description: @@ -1603,10 +1586,10 @@ packages: dependency: transitive description: name: web - sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 url: "https://pub.dev" source: hosted - version: "0.1.4-beta" + version: "0.3.0" web_socket_channel: dependency: transitive description: @@ -1615,6 +1598,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: d81b68e88cc353e546afb93fb38958e3717282c5ac6e5d3be4a4aef9fc3c1413 + url: "https://pub.dev" + source: hosted + version: "4.5.0" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "3e5f4e9d818086b0d01a66fb1ff9cc72ab0cc58c71980e3d3661c5685ea0efb0" + url: "https://pub.dev" + source: hosted + version: "3.15.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: dbe745ee459a16b6fec296f7565a8ef430d0d681001d8ae521898b9361854943 + url: "https://pub.dev" + source: hosted + version: "2.9.0" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "9bf168bccdf179ce90450b5f37e36fe263f591c9338828d6bf09b6f8d0f57f86" + url: "https://pub.dev" + source: hosted + version: "3.12.0" win32: dependency: transitive description: @@ -1656,5 +1671,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.1.3 <4.0.0" - flutter: ">=3.13.0" + dart: ">=3.2.3 <4.0.0" + flutter: ">=3.16.6"