add 'login on other device'

This commit is contained in:
Pato05 2024-02-12 03:37:26 +01:00
parent e827549c1d
commit b8f0bb2140
No known key found for this signature in database
GPG key ID: ED4C6F9C3D574FB6
16 changed files with 642 additions and 133 deletions

View file

@ -24,10 +24,10 @@ class DeezerAudioSource extends StreamAudioSource {
late AudioQuality Function() _getQuality; late AudioQuality Function() _getQuality;
late String _trackId; late String _trackId;
late String _md5origin; late String? _md5origin;
late String _mediaVersion; late String? _mediaVersion;
final String trackToken; String? _trackToken;
final int trackTokenExpiration; int? _trackTokenExpiration;
final StreamInfoCallback? onStreamObtained; final StreamInfoCallback? onStreamObtained;
// some cache // some cache
@ -39,28 +39,28 @@ class DeezerAudioSource extends StreamAudioSource {
DeezerAudioSource({ DeezerAudioSource({
required AudioQuality Function() getQuality, required AudioQuality Function() getQuality,
required String trackId, required String trackId,
required String md5origin, String? md5origin,
required String mediaVersion, String? mediaVersion,
required this.trackToken, String? trackToken,
required this.trackTokenExpiration, int? trackTokenExpiration,
this.onStreamObtained, this.onStreamObtained,
}) : _deezerAudio = DeezerAudio( }) : _deezerAudio = DeezerAudio(
deezerAPI: deezerAPI, deezerAPI: deezerAPI,
md5origin: md5origin,
quality: getQuality.call(), quality: getQuality.call(),
trackId: trackId, trackId: trackId,
mediaVersion: mediaVersion,
) { ) {
_getQuality = getQuality; _getQuality = getQuality;
_trackId = trackId; _trackId = trackId;
_md5origin = md5origin; _md5origin = md5origin;
_mediaVersion = mediaVersion; _mediaVersion = mediaVersion;
_trackToken = trackToken;
_trackTokenExpiration = trackTokenExpiration;
} }
AudioQuality? get quality => _deezerAudio.quality; AudioQuality? get quality => _deezerAudio.quality;
String get trackId => _trackId; String get trackId => _trackId;
String get md5origin => _md5origin; String? get md5origin => _md5origin;
String get mediaVersion => _mediaVersion; String? get mediaVersion => _mediaVersion;
@override @override
Future<StreamAudioResponse> request([int? start, int? end]) async { Future<StreamAudioResponse> request([int? start, int? end]) async {
@ -94,16 +94,32 @@ class DeezerAudioSource extends StreamAudioSource {
_deezerAudio.quality = newQuality; _deezerAudio.quality = newQuality;
if (_downloadUrl == null) { if (_downloadUrl == null) {
final gottenUrl = if (_trackToken == null) {
await _deezerAudio.getUrl(trackToken, trackTokenExpiration); // TODO: get new track token?
if (gottenUrl != null) { final track = await deezerAPI.track(trackId);
_downloadUrl = gottenUrl; _trackToken = track.trackToken;
} else { _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!'); _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 // OLD URL GENERATION
try { 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 { } on QualityException {
rethrow; rethrow;
} }

View file

@ -1,9 +1,8 @@
import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/definitions.dart'; import 'package:freezer/api/definitions.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'deezer.dart';
class UrlAudioSource extends StreamAudioSource { class UrlAudioSource extends StreamAudioSource {
final Uri uri; final Uri uri;
final StreamInfoCallback? onStreamObtained; final StreamInfoCallback? onStreamObtained;
@ -13,7 +12,7 @@ class UrlAudioSource extends StreamAudioSource {
Future<StreamAudioResponse> request([int? start, int? end]) async { Future<StreamAudioResponse> request([int? start, int? end]) async {
final req = http.Request('GET', uri) final req = http.Request('GET', uri)
..headers.addAll({ ..headers.addAll({
'User-Agent': deezerAPI.headers['User-Agent']!, 'User-Agent': DeezerAPI.userAgent,
'Accept-Language': '*', 'Accept-Language': '*',
'Accept': '*/*', 'Accept': '*/*',
if (start != null || end != null) if (start != null || end != null)

View file

@ -383,7 +383,7 @@ class DeezerAPI {
return SearchResults.fromPrivateJson(data['results']); return SearchResults.fromPrivateJson(data['results']);
} }
Future<Track> track(String? id) async { Future<Track> track(String id) async {
Map<dynamic, dynamic> data = await callApi('song.getListData', params: { Map<dynamic, dynamic> data = await callApi('song.getListData', params: {
'sng_ids': [id] 'sng_ids': [id]
}); });

View file

@ -14,32 +14,31 @@ class DeezerAudio {
static const chunkSize = 2048; static const chunkSize = 2048;
final DeezerAPI deezerAPI; final DeezerAPI deezerAPI;
String md5origin;
AudioQuality quality; AudioQuality quality;
String trackId; String trackId;
String mediaVersion;
DeezerAudio({ DeezerAudio({
required this.deezerAPI, required this.deezerAPI,
required this.md5origin,
required this.quality, required this.quality,
required this.trackId, required this.trackId,
required this.mediaVersion,
}); });
static final _logger = Logger('DeezerAudio'); static final _logger = Logger('DeezerAudio');
Future<Uri> fallback() async { Future<({Uri uri, String md5origin, String mediaVersion})> fallback(
{required String md5origin, required String mediaVersion}) async {
final res = await fallbackUrl( final res = await fallbackUrl(
md5origin: md5origin, md5origin: md5origin,
preferredQuality: quality, preferredQuality: quality,
trackId: trackId, trackId: trackId,
mediaVersion: mediaVersion); mediaVersion: mediaVersion);
md5origin = res.md5origin;
quality = res.quality; quality = res.quality;
trackId = res.trackId; trackId = res.trackId;
mediaVersion = res.mediaVersion; return (
return res.uri; uri: res.uri,
md5origin: res.md5origin,
mediaVersion: res.mediaVersion
);
} }
Future< Future<
@ -281,6 +280,9 @@ class DeezerAudio {
} }
} }
static bool isTokenExpired(int trackTokenExpiration) =>
DateTime.now().millisecondsSinceEpoch ~/ 1000 > trackTokenExpiration;
Future<Uri?> getUrl(String trackToken, int expiration) => Future<Uri?> getUrl(String trackToken, int expiration) =>
getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality); getTrackUrl(deezerAPI, trackId, trackToken, expiration, quality: quality);
@ -292,10 +294,7 @@ class DeezerAudio {
required AudioQuality quality, required AudioQuality quality,
}) async { }) async {
final String actualTrackToken; final String actualTrackToken;
if (DateTime.now().millisecondsSinceEpoch ~/ 1000 > expiration) { if (isTokenExpired(expiration)) {
if (!deezerAPI.canStreamHQ && !deezerAPI.canStreamLossless) {
_logger.fine('using old url generation, token expired.');
}
final newTrack = await deezerAPI.track(trackId); final newTrack = await deezerAPI.track(trackId);
actualTrackToken = newTrack.trackToken!; actualTrackToken = newTrack.trackToken!;
} else { } else {

View file

@ -161,7 +161,7 @@ class DownloadManager {
bool isSingleton = false}) async { bool isSingleton = false}) async {
if (!isSupported) return false; if (!isSupported) return false;
//Permission //Permission
if (!private && !(await checkPermission())) return false; if (!private && !(await checkCanDownload())) return false;
//Ask for quality //Ask for quality
AudioQuality? quality; AudioQuality? quality;
@ -201,7 +201,7 @@ class DownloadManager {
Future addOfflineAlbum(Album? album, Future addOfflineAlbum(Album? album,
{private = true, BuildContext? context}) async { {private = true, BuildContext? context}) async {
//Permission //Permission
if (!private && !(await checkPermission())) return; if (!private && !(await checkCanDownload())) return;
//Ask for quality //Ask for quality
AudioQuality? quality; AudioQuality? quality;
@ -245,7 +245,7 @@ class DownloadManager {
if (!isSupported) return false; if (!isSupported) return false;
//Permission //Permission
if (!private && !(await checkPermission())) return; if (!private && !(await checkCanDownload())) return;
//Ask for quality //Ask for quality
if (!private && if (!private &&
@ -629,15 +629,20 @@ class DownloadManager {
'updateSettings', settings.getServiceSettings()); 'updateSettings', settings.getServiceSettings());
} }
//Check storage permission static Future<bool> checkCanDownload() {
Future<bool> checkPermission() async {
if (settings.downloadPath == null) { if (settings.downloadPath == null) {
Fluttertoast.showToast( Fluttertoast.showToast(
msg: 'Set download path in settings first!'.i18n, msg: 'Set download path in settings first!'.i18n,
toastLength: Toast.LENGTH_LONG, toastLength: Toast.LENGTH_LONG,
gravity: ToastGravity.BOTTOM); gravity: ToastGravity.BOTTOM);
return false; return Future.value(false);
} }
return checkPermission();
}
//Check storage permission
static Future<bool> checkPermission() async {
if (await Permission.storage.request().isGranted) { if (await Permission.storage.request().isGranted) {
return true; return true;
} else if ( // android 12 or later } else if ( // android 12 or later

View file

@ -86,7 +86,9 @@ class DownloadManager {
Future<bool> addOfflineTrack(d.Track track, Future<bool> addOfflineTrack(d.Track track,
{bool private = true, BuildContext? context, isSingleton = false}) async { {bool private = true, BuildContext? context, isSingleton = false}) async {
//Permission //Permission
//if (!private && !(await checkPermission())) return false; if (!private && !(await dl.DownloadManager.checkCanDownload())) {
return false;
}
//Ask for quality //Ask for quality
//AudioQuality? quality; //AudioQuality? quality;
@ -127,6 +129,6 @@ class DownloadManager {
static void _startNative(ServiceInstance service) => static void _startNative(ServiceInstance service) =>
_startService(ServiceInterface(service: service)); _startService(ServiceInterface(service: service));
static void _startService(ServiceInterface? service) => static void _startService(ServiceInterface service) =>
DownloadService(service).run(); DownloadService(service).run();
} }

View file

@ -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/api/download_manager/service_interface.dart';
import 'package:freezer/settings.dart';
class DownloadService { class DownloadService {
static const NOTIFICATION_ID = 6969; static const NOTIFICATION_ID = 6969;
@ -7,7 +11,30 @@ class DownloadService {
final ServiceInterface service; final ServiceInterface service;
DownloadService(this.service); DownloadService(this.service);
AudioQuality? _preferredQuality;
AudioQuality? _downloadQuality;
bool useGetURL = false;
void run() { void run() {
service.on('addDownloads').listen((event) {}); 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 =
// }
} }
} }

View file

@ -3,11 +3,11 @@ import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:freezer/api/cache.dart'; import 'package:freezer/api/cache.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/deezer_audio_source.dart'; import 'package:freezer/api/audio_sources/deezer_audio_source.dart';
import 'package:freezer/api/offline_audio_source.dart'; import 'package:freezer/api/audio_sources/offline_audio_source.dart';
import 'package:freezer/api/paths.dart'; import 'package:freezer/api/paths.dart';
import 'package:freezer/api/player/player_helper.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:freezer/ui/android_auto.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
@ -153,6 +153,7 @@ class AudioPlayerTask extends BaseAudioHandler {
// Linux/Windows specific options // Linux/Windows specific options
JustAudioMediaKit.title = 'Freezer'; JustAudioMediaKit.title = 'Freezer';
JustAudioMediaKit.protocolWhitelist = const ['http']; JustAudioMediaKit.protocolWhitelist = const ['http'];
//JustAudioMediaKit.bufferSize = 128;
_deezerAPI = initArgs.deezerAPI; _deezerAPI = initArgs.deezerAPI;
_androidAuto = AndroidAuto(deezerAPI: _deezerAPI); _androidAuto = AndroidAuto(deezerAPI: _deezerAPI);
@ -624,9 +625,10 @@ class AudioPlayerTask extends BaseAudioHandler {
//This just returns fake url that contains metadata //This just returns fake url that contains metadata
List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']); List? playbackDetails = jsonDecode(mediaItem.extras!['playbackDetails']);
if ((playbackDetails ?? []).length < 2) { // DON'T CARE, WE DON'T NEED THOSE WITH NEW API
throw Exception('not enough playback details'); // 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}'; //String url = 'https://dzcdn.net/?md5=${playbackDetails[0]}&mv=${playbackDetails[1]}&q=${quality.toString()}#${mediaItem.id}';
// final uri = Uri.http('localhost:36958', '', { // final uri = Uri.http('localhost:36958', '', {
@ -642,10 +644,12 @@ class AudioPlayerTask extends BaseAudioHandler {
return DeezerAudioSource( return DeezerAudioSource(
getQuality: () => _currentQuality, getQuality: () => _currentQuality,
trackId: mediaItem.id, trackId: mediaItem.id,
trackToken: mediaItem.extras!['trackToken'] ?? '', // THESE NEXT 4 CAN BE NULL.
trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'] ?? 0, // IF THEY ARE NULL, THEY'RE GONNA BE FETCHED LATER ON.
md5origin: playbackDetails![0], trackToken: mediaItem.extras!['trackToken'],
mediaVersion: playbackDetails[1], trackTokenExpiration: mediaItem.extras!['trackTokenExpiration'],
md5origin: playbackDetails?[0],
mediaVersion: playbackDetails?[1],
onStreamObtained: (qualityInfo) => onStreamObtained: (qualityInfo) =>
customEvent.add({'action': 'streamInfo', 'data': qualityInfo}), customEvent.add({'action': 'streamInfo', 'data': qualityInfo}),
); );

View file

@ -181,6 +181,15 @@ class Settings {
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) { void checkQuality(bool canStreamHQ, bool canStreamLossless) {
if (canStreamLossless) return; if (canStreamLossless) return;

View file

@ -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<LoginOnOtherDevice> createState() => _LoginOnOtherDeviceState();
}
class _LoginOnOtherDeviceState extends State<LoginOnOtherDevice> {
String _ipAddress = '';
String _code = '';
String? _error;
bool _loading = false;
bool _step2 = false;
final GlobalKey<FormState> _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))
],
);
}
}

View file

@ -4,11 +4,16 @@ import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:async/async.dart'; 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/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
import 'package:freezer/translations.i18n.dart'; import 'package:freezer/translations.i18n.dart';
import 'package:freezer/utils.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:network_info_plus/network_info_plus.dart'; import 'package:network_info_plus/network_info_plus.dart';
import 'package:rxdart/rxdart.dart'; import 'package:rxdart/rxdart.dart';
@ -216,7 +221,7 @@ class _LoginWidgetState extends State<LoginWidget> {
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16.0), style: const TextStyle(fontSize: 16.0),
), ),
const SizedBox(height: 16.0), const SizedBox(height: 32.0),
ElevatedButton( ElevatedButton(
child: child:
Text('Login using other device'.i18n), Text('Login using other device'.i18n),
@ -242,12 +247,13 @@ class _LoginWidgetState extends State<LoginWidget> {
// const SizedBox(height: 2.0), // const SizedBox(height: 2.0),
// only supported on android // only supported on android
if (Platform.isAndroid) if (Platform.isAndroid) ...[
ElevatedButton( ElevatedButton(
onPressed: _loginBrowser, onPressed: _loginBrowser,
child: Text('Login using browser'.i18n), child: Text('Login using browser'.i18n),
), ),
const SizedBox(height: 2.0), const SizedBox(height: 2.0),
],
ElevatedButton( ElevatedButton(
child: Text('Login using token'.i18n), child: Text('Login using token'.i18n),
onPressed: () { onPressed: () {
@ -441,16 +447,14 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
late int _code; late int _code;
late Timer _codeTimer; late Timer _codeTimer;
final _timerNotifier = ValueNotifier<double>(0.0); final _timerNotifier = ValueNotifier<double>(0.0);
bool step2 = false; bool _step2 = false;
final _logger = Logger('OtherDeviceLogin'); final _logger = Logger('OtherDeviceLogin');
void _generateCode() { void _generateCode() {
_code = Random.secure().nextInt(899999) + 100000; _code = Random.secure().nextInt(899999) + 100000;
} }
Future<void> _initServer() async { void _initTimer() {
_server = await HttpServer.bind(InternetAddress.anyIPv4, 0);
_deviceIP = await NetworkInfo().getWifiIP();
_generateCode(); _generateCode();
const tps = 30 * 1000 / 50; const tps = 30 * 1000 / 50;
_codeTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) { _codeTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) {
@ -460,14 +464,30 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
setState(() => _generateCode()); setState(() => _generateCode());
} }
}); });
}
Future<void> _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 { _serverSubscription = _server.listen((request) async {
final buffer = Uint8List(0); final buffer = <int>[];
final reqCompleter = Completer<void>(); final reqCompleter = Completer<void>();
final subs = request.listen( final subs = request.listen(
(data) { (data) {
if (data.length + buffer.length > 8192) { if (data.length + buffer.length > 8192) {
_logger.severe('Request too big!'); _logger.severe('Request too big!');
request.response.close(); request.response.close();
reqCompleter.completeError("Request too big!");
return;
} }
buffer.addAll(data); buffer.addAll(data);
@ -476,13 +496,129 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
reqCompleter.complete(); reqCompleter.complete();
}, },
); );
await reqCompleter.future;
subs.cancel();
try { 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) { } catch (e) {
_logger.severe('Error $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<OtherDeviceLogin> {
return AlertDialog( return AlertDialog(
title: Text('Login using other device'.i18n), title: Text('Login using other device'.i18n),
contentPadding: const EdgeInsets.only(top: 12), contentPadding: const EdgeInsets.only(top: 12),
content: step2 content: _step2
? Text('Please follow the on-screen instructions'.i18n, ? Column(
style: Theme.of(context).textTheme.bodyLarge) 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( : FutureBuilder(
future: _serverReady, future: _serverReady,
builder: (context, snapshot) { builder: (context, snapshot) {
@ -523,7 +668,7 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
ValueListenableBuilder( ValueListenableBuilder(
valueListenable: _timerNotifier, valueListenable: _timerNotifier,
builder: (context, value, _) => builder: (context, value, _) =>
LinearProgressIndicator(value: value), LinearProgressIndicator(value: 1.0 - value),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(24, 18, 24, 24), padding: const EdgeInsets.fromLTRB(24, 18, 24, 24),
@ -532,24 +677,23 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( 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.' 'On your other device, go to Freezer\'s settings > General > Login on other device and input the parameters below'
.i18n), .i18n,
style: Theme.of(context).textTheme.bodyLarge,
),
RichText( RichText(
textAlign: TextAlign.start,
text: TextSpan( text: TextSpan(
style: style:
Theme.of(context).textTheme.bodyMedium, Theme.of(context).textTheme.bodyMedium,
children: [ children: [
TextSpan(text: 'IP Address: '.i18n), TextSpan(text: 'IP Address: '.i18n),
TextSpan( TextSpan(
text: text:
_deviceIP ?? 'Could not get IP!', '${_deviceIP ?? 'Could not get IP!'}:${_server.port}',
style: TextStyle(fontSize: 32.sp)), style: Theme.of(context)
const TextSpan(text: ':'), .textTheme
TextSpan( .displaySmall),
text: _server.port.toString(), ])),
style: TextStyle(fontSize: 32.sp)),
])),
RichText( RichText(
text: TextSpan( text: TextSpan(
style: style:
@ -558,7 +702,9 @@ class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
TextSpan(text: 'Code: '.i18n), TextSpan(text: 'Code: '.i18n),
TextSpan( TextSpan(
text: '$_code', text: '$_code',
style: TextStyle(fontSize: 32.sp)), style: Theme.of(context)
.textTheme
.displaySmall),
])), ])),
], ],
), ),

View file

@ -28,7 +28,7 @@ FutureOr openScreenByURL(BuildContext context, String url) async {
switch (res.type) { switch (res.type) {
case DeezerLinkType.TRACK: case DeezerLinkType.TRACK:
Track t = await deezerAPI.track(res.id); Track t = await deezerAPI.track(res.id!);
MenuSheet(context).defaultTrackMenu(t); MenuSheet(context).defaultTrackMenu(t);
break; break;
case DeezerLinkType.ALBUM: case DeezerLinkType.ALBUM:

View file

@ -11,6 +11,7 @@ import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:fluttericon/web_symbols_icons.dart'; import 'package:fluttericon/web_symbols_icons.dart';
import 'package:fluttertoast/fluttertoast.dart'; import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezer/api/definitions.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:package_info_plus/package_info_plus.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:scrobblenaut/scrobblenaut.dart'; import 'package:scrobblenaut/scrobblenaut.dart';
@ -1051,7 +1052,7 @@ class _DownloadsSettingsState extends State<DownloadsSettings> {
settings.downloadPath ?? 'Not set, click here to set!'.i18n), settings.downloadPath ?? 'Not set, click here to set!'.i18n),
onTap: () async { onTap: () async {
//Check permissions //Check permissions
if (!await downloadManager.checkPermission()) { if (!await DownloadManager.checkPermission()) {
return; return;
} }
DownloadManager.getDirectory('Pick-a-Path'.i18n).then((path) { DownloadManager.getDirectory('Pick-a-Path'.i18n).then((path) {
@ -1448,6 +1449,13 @@ class _GeneralSettingsState extends State<GeneralSettings> {
setState(() {}); 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( ListTile(
title: Text( title: Text(
'Log out'.i18n, 'Log out'.i18n,

30
lib/utils.dart Normal file
View file

@ -0,0 +1,30 @@
import 'dart:typed_data';
class Utils {
static List<int> splitDigits(int number) {
final digits = <int>[];
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;
}
}

View file

@ -221,10 +221,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: collection name: collection
sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.2" version: "1.18.0"
connectivity_plus: connectivity_plus:
dependency: "direct main" dependency: "direct main"
description: description:
@ -362,15 +362,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.0.3" 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: equatable:
dependency: transitive dependency: transitive
description: description:
@ -505,22 +496,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -901,10 +876,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "3c74dbf8763d36539f114c799d8a2d87343b5067e9d796ca22b5eb8437090ee3" sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.10.0"
mime: mime:
dependency: transitive dependency: transitive
description: description:
@ -1085,42 +1060,50 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: permission_handler name: permission_handler
sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 sha256: "3c84d49f0a5e1915364707159ab71f11b3b8a429532176d3a6248a45718ad4f9"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.4.5" version: "11.2.1"
permission_handler_android: permission_handler_android:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_android name: permission_handler_android
sha256: "59c6322171c29df93a22d150ad95f3aa19ed86542eaec409ab2691b8f35f9a47" sha256: a5ebaa420cee8fd880ef10dedd42c6b3f493e7dbe27d7e0a7e1798669373082a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.3.6" version: "12.0.4"
permission_handler_apple: permission_handler_apple:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_apple name: permission_handler_apple
sha256: "99e220bce3f8877c78e4ace901082fb29fa1b4ebde529ad0932d8d664b34f3f5" sha256: "6ca25ee52518a8a26e80aaefe3c71caf6e2dfd809c1b20900d0882df6faed36e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: permission_handler_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_platform_interface name: permission_handler_platform_interface
sha256: "6760eb5ef34589224771010805bea6054ad28453906936f843a8cc4d3a55c4a4" sha256: "5c43148f2bfb6d14c5a8162c0a712afe891f2d847f35fcff29c406b37da43c3c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.12.0" version: "4.1.0"
permission_handler_windows: permission_handler_windows:
dependency: transitive dependency: transitive
description: description:
name: permission_handler_windows name: permission_handler_windows
sha256: cc074aace208760f1eee6aa4fae766b45d947df85bc831cde77009cdb4720098 sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.3" version: "0.2.1"
petitparser: petitparser:
dependency: transitive dependency: transitive
description: description:
@ -1355,18 +1338,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: stack_trace name: stack_trace
sha256: c3c7d8edb15bee7f0f74debd4b9c5f3c2ea86766fe4178eb2a18eb30a0bdaed5 sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.11.0" version: "1.11.1"
stream_channel: stream_channel:
dependency: transitive dependency: transitive
description: description:
name: stream_channel name: stream_channel
sha256: "83615bee9045c1d322bbbd1ba209b7a749c2cbcdcb3fdd1df8eb488b3279c1c8" sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.1" version: "2.1.2"
stream_transform: stream_transform:
dependency: transitive dependency: transitive
description: description:
@ -1403,10 +1386,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "75760ffd7786fffdfb9597c35c5b27eaeec82be8edfb6d71d32651128ed7aab8" sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.0" version: "0.6.1"
time: time:
dependency: transitive dependency: transitive
description: description:
@ -1603,10 +1586,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: web name: web
sha256: dc8ccd225a2005c1be616fe02951e2e342092edf968cf0844220383757ef8f10 sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.1.4-beta" version: "0.3.0"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: transitive
description: description:
@ -1615,6 +1598,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" 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: win32:
dependency: transitive dependency: transitive
description: description:
@ -1656,5 +1671,5 @@ packages:
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.1.3 <4.0.0" dart: ">=3.2.3 <4.0.0"
flutter: ">=3.13.0" flutter: ">=3.16.6"