add 'login on other device'
This commit is contained in:
parent
e827549c1d
commit
b8f0bb2140
|
@ -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<StreamAudioResponse> 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;
|
||||
}
|
|
@ -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<StreamAudioResponse> 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)
|
|
@ -383,7 +383,7 @@ class DeezerAPI {
|
|||
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: {
|
||||
'sng_ids': [id]
|
||||
});
|
||||
|
|
|
@ -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<Uri> 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<Uri?> 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 {
|
||||
|
|
|
@ -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<bool> checkPermission() async {
|
||||
static Future<bool> 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<bool> checkPermission() async {
|
||||
if (await Permission.storage.request().isGranted) {
|
||||
return true;
|
||||
} else if ( // android 12 or later
|
||||
|
|
|
@ -86,7 +86,9 @@ class DownloadManager {
|
|||
Future<bool> 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();
|
||||
}
|
||||
|
|
|
@ -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 =
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}),
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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))
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<LoginWidget> {
|
|||
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<LoginWidget> {
|
|||
// 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<OtherDeviceLogin> {
|
|||
late int _code;
|
||||
late Timer _codeTimer;
|
||||
final _timerNotifier = ValueNotifier<double>(0.0);
|
||||
bool step2 = false;
|
||||
bool _step2 = false;
|
||||
final _logger = Logger('OtherDeviceLogin');
|
||||
|
||||
void _generateCode() {
|
||||
_code = Random.secure().nextInt(899999) + 100000;
|
||||
}
|
||||
|
||||
Future<void> _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<OtherDeviceLogin> {
|
|||
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 {
|
||||
final buffer = Uint8List(0);
|
||||
final buffer = <int>[];
|
||||
final reqCompleter = Completer<void>();
|
||||
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<OtherDeviceLogin> {
|
|||
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<OtherDeviceLogin> {
|
|||
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<OtherDeviceLogin> {
|
|||
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<OtherDeviceLogin> {
|
|||
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<OtherDeviceLogin> {
|
|||
TextSpan(text: 'Code: '.i18n),
|
||||
TextSpan(
|
||||
text: '$_code',
|
||||
style: TextStyle(fontSize: 32.sp)),
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall),
|
||||
])),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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<DownloadsSettings> {
|
|||
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<GeneralSettings> {
|
|||
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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
113
pubspec.lock
113
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"
|
||||
|
|
Loading…
Reference in New Issue