import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:crypto/crypto.dart'; import 'package:encrypt/encrypt.dart' as encrypt; import 'package:pointycastle/asymmetric/api.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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:url_launcher/url_launcher_string.dart'; import 'package:webview_flutter/webview_flutter.dart'; import '../settings.dart'; import '../api/definitions.dart'; import 'home_screen.dart'; class LoginWidget extends StatefulWidget { final Function? callback; const LoginWidget({this.callback, Key? key}) : super(key: key); @override State createState() => _LoginWidgetState(); } class _LoginWidgetState extends State { late String _arl; String? _error; //Initialize deezer etc Future _init() async { deezerAPI.arl = settings.arl; //Pre-cache homepage if (!await HomePage.exists('')) { await deezerAPI.authorize(); settings.offlineMode = false; HomePage hp = await deezerAPI.homePage(); await hp.save(''); } } //Call _init() void _start() async { if (settings.arl != null) { _init().then((_) { if (widget.callback != null) widget.callback!(); }); } } //Check if deezer available in current country void _checkAvailability() async { bool? available = await DeezerAPI.chceckAvailability(); if (!(available ?? true)) { showDialog( context: context, builder: (context) => AlertDialog( title: Text("Deezer is unavailable".i18n), content: Text( "Deezer is unavailable in your country, Freezer might not work properly. Please use a VPN" .i18n), actions: [ TextButton( child: Text('Continue'.i18n), onPressed: () => Navigator.of(context).pop(), ) ], )); } } @override void didUpdateWidget(LoginWidget oldWidget) { _start(); super.didUpdateWidget(oldWidget); } @override void initState() { _start(); _checkAvailability(); super.initState(); } void errorDialog() { showDialog( context: context, builder: (context) { return AlertDialog( title: Text('Error'.i18n), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Error logging in! Please check your token and internet connection and try again.' .i18n), if (_error != null) Text('\n\n$_error') ], ), actions: [ TextButton( child: Text('Dismiss'.i18n), onPressed: () { Navigator.of(context).pop(); }, ) ], ); }); } void _update(String arl) async { settings.arl = arl; await settings.save(); setState(() => {}); //Try logging in try { deezerAPI.arl = settings.arl; bool resp = await deezerAPI.rawAuthorize( onError: (e) => setState(() => _error = e.toString())); if (resp == false) { //false, not null if (settings.arl!.length != 192) { _error ??= ''; _error = 'Invalid ARL length!'; } setState(() => settings.arl = null); errorDialog(); } //On error show dialog and reset to null } catch (e) { _error = e.toString(); print('Login error: $e'); setState(() => settings.arl = null); errorDialog(); } await settings.save(); _start(); } // ARL auth: called on "Save" click, Enter and DPAD_Center press void goARL(FocusNode? node, TextEditingController controller) { if (node != null) { node.unfocus(); } controller.clear(); Navigator.of(context).pop(); _update(_arl.trim()); } void _loginBrowser() async { Navigator.of(context) .pushRoute(builder: (context) => LoginBrowser(_update)); } @override Widget build(BuildContext context) { //If arl non null, show loading if (settings.arl != null) { return const Scaffold( body: Center( child: CircularProgressIndicator(), ), ); } TextEditingController controller = TextEditingController(); // For "DPAD center" key handling on remote controls FocusNode focusNode = FocusNode( skipTraversal: true, descendantsAreFocusable: false, onKey: (node, event) { if (event.logicalKey == LogicalKeyboardKey.select) { goARL(node, controller); } return KeyEventResult.handled; }); return Scaffold( body: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0), child: Theme( data: Theme.of(context).copyWith( outlinedButtonTheme: OutlinedButtonThemeData( style: ButtonStyle( foregroundColor: MaterialStateProperty.all(Colors.white)))), //data: ThemeData( // outlinedButtonTheme: OutlinedButtonThemeData( // style: ButtonStyle( // foregroundColor: // MaterialStateProperty.all(Colors.white)))), child: SingleChildScrollView( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 700.0), child: Column( children: [ ConstrainedBox( constraints: BoxConstraints( minHeight: MediaQuery.of(context).size.height - 250.0), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 32.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ const FreezerTitle(), const SizedBox(height: 16.0), Text( "Please login using your Deezer account." .i18n, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16.0), ), const SizedBox(height: 32.0), ElevatedButton( child: Text('Login using other device'.i18n), onPressed: () { showDialog( context: context, builder: (context) => OtherDeviceLogin(_update)); }), const SizedBox(height: 2.0), //Email login dialog (Not working anymore) // ElevatedButton( // child: Text( // 'Login using email'.i18n, // ), // onPressed: () { // showDialog( // context: context, // builder: (context) => // EmailLogin(_update)); // }, // ), // const SizedBox(height: 2.0), // only supported on android if (LoginBrowser.supported()) ...[ ElevatedButton( onPressed: _loginBrowser, child: Text('Login using browser'.i18n), ), const SizedBox(height: 2.0), ], ElevatedButton( child: Text('Login with ARL'.i18n), onPressed: () { showDialog( context: context, builder: (context) { Future.delayed( const Duration(seconds: 1), () => { focusNode.requestFocus() }); // autofocus doesn't work - it's replacement return AlertDialog( title: Text('Enter ARL'.i18n), content: TextField( onChanged: (String s) => _arl = s, decoration: InputDecoration( labelText: 'Token (ARL)'.i18n), focusNode: focusNode, controller: controller, onSubmitted: (String s) { goARL(focusNode, controller); }, ), actions: [ TextButton( child: Text('Save'.i18n), onPressed: () => goARL(null, controller), ) ], ); }); }, ), ])), ), Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 16.0), Text( "If you don't have account, you can register on deezer.com for free." .i18n, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16.0), ), const SizedBox(height: 8.0), Padding( padding: const EdgeInsets.symmetric(horizontal: 32.0), child: ElevatedButton( child: Text('Open in browser'.i18n), onPressed: () { launchUrlString('https://deezer.com/register'); }, ), ), const SizedBox(height: 8.0), const Divider(), const SizedBox(height: 8.0), Text( "By using this app, you don't agree with the Deezer ToS" .i18n, textAlign: TextAlign.center, style: const TextStyle(fontSize: 14.0), ) ], ) ], ), ), ), ), ), ), )); } } class LoadingWindowWait extends StatelessWidget { final VoidCallback update; const LoadingWindowWait({super.key, required this.update}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Text( 'Please login by using the floating window'.i18n, style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16.0), const CircularProgressIndicator(), ]), ), ); } } class LoginBrowser extends StatefulWidget { static bool supported() => Platform.isAndroid || Platform.isIOS; final void Function(String arl) arlCallback; const LoginBrowser(this.arlCallback, {super.key}); @override State createState() => _LoginBrowserState(); } class _LoginBrowserState extends State { late final WebViewController _controller; @override void initState() { _controller = WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..setBackgroundColor(const Color(0x00000000)) // Chrome on Android 14 ..setUserAgent( 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.6167.101 Mobile Safari/537.36') ..setNavigationDelegate( NavigationDelegate( onProgress: (int progress) { // Update loading bar. }, onPageStarted: (String url) {}, onPageFinished: (String url) {}, onWebResourceError: (WebResourceError error) {}, onNavigationRequest: (NavigationRequest request) { final uri = Uri.parse(request.url); print('uri $uri'); //Parse arl from url if (uri.host == 'deezer.page.link') { try { //Actual url is in `link` query parameter Uri linkUri = Uri.parse(uri.queryParameters['link']!); String? arl = linkUri.queryParameters['arl']; if (arl != null) { Navigator.of(context).pop(); widget.arlCallback(arl); return NavigationDecision.prevent; } } catch (e) { print(e); } } else if (!uri.path.contains('/login') && !uri.path.contains('/register') && !uri.path.contains('/open_app')) { _controller.loadRequest(Uri.parse( 'https://deezer.com/open_app?page=home&source=MF_MenuDownloadApp')); return NavigationDecision.prevent; } return NavigationDecision.navigate; }, ), ) ..loadRequest(Uri.parse('https://deezer.com/login')); super.initState(); } @override void dispose() { // clear everything unawaited(_controller.clearCache()); unawaited(_controller.clearLocalStorage()); super.dispose(); } @override Widget build(BuildContext context) { return SafeArea( child: WebViewWidget( controller: _controller, ), ); } } class OtherDeviceLogin extends StatefulWidget { final void Function(String arl) arlCallback; const OtherDeviceLogin(this.arlCallback, {super.key}); @override State createState() => _OtherDeviceLoginState(); } class _OtherDeviceLoginState extends State { late final HttpServer _server; late final StreamSubscription _serverSubscription; late final String? _deviceIP; late final Future _serverReady; late int _code; late Timer _codeTimer; final _timerNotifier = ValueNotifier(0.0); bool _step2 = false; final _logger = Logger('OtherDeviceLogin'); void _generateCode() { _code = Random.secure().nextInt(899999) + 100000; } void _initTimer() { _generateCode(); const tps = 30 * 1000 / 50; _codeTimer = Timer.periodic(const Duration(milliseconds: 50), (timer) { final a = timer.tick / tps; _timerNotifier.value = a - a.truncate(); if (timer.tick % tps == 0) { 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 = []; 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); }, onDone: () { reqCompleter.complete(); }, ); try { await reqCompleter.future; } catch (e) { return; } subs.cancel(); late final Map data; try { data = jsonDecode(utf8.decode(buffer)); } catch (e) { _logger.severe('Error $e'); 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(); Navigator.pop(context); widget.arlCallback(decryptedArl); 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; } }); } @override void initState() { _serverReady = _initServer(); super.initState(); } @override void dispose() { _server.close(); _serverSubscription.cancel(); _codeTimer.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return AlertDialog( title: Text('Login using other device'.i18n), contentPadding: const EdgeInsets.only(top: 12), 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) { if (snapshot.connectionState != ConnectionState.done) { return const Row(children: [CircularProgressIndicator()]); } return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ ValueListenableBuilder( valueListenable: _timerNotifier, builder: (context, value, _) => LinearProgressIndicator(value: 1.0 - value), ), Padding( padding: const EdgeInsets.fromLTRB(24, 18, 24, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '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( text: TextSpan( style: Theme.of(context).textTheme.bodyMedium, children: [ TextSpan(text: 'IP Address: '.i18n), TextSpan( text: '${_deviceIP ?? 'Could not get IP!'}:${_server.port}', style: Theme.of(context) .textTheme .displaySmall), ])), RichText( text: TextSpan( style: Theme.of(context).textTheme.bodyMedium, children: [ TextSpan(text: 'Code: '.i18n), TextSpan( text: '$_code', style: Theme.of(context) .textTheme .displaySmall), ])), ], ), ) ]); }), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('Cancel'.i18n)) ], ); } } // email login is removed cuz not working = USELESS //class EmailLogin extends StatefulWidget { // final Function callback; // const EmailLogin(this.callback, {Key? key}) : super(key: key); // // @override // State createState() => _EmailLoginState(); //} // //class _EmailLoginState extends State { // final _emailController = TextEditingController(); // final _passwordController = TextEditingController(); // bool _loading = false; // // Future _login() async { // setState(() => _loading = true); // //Try logging in // String? arl; // String? exception; // try { // arl = await deezerAPI.getArlByEmail( // _emailController.text, _passwordController.text); // } catch (e, st) { // exception = e.toString(); // print(e); // print(st); // } // setState(() => _loading = false); // // //Success // if (arl != null) { // settings.arl = arl; // Navigator.of(context).pop(); // widget.callback(); // return; // } // // //Error // showDialog( // context: context, // builder: (context) => AlertDialog( // title: Text("Error logging in!".i18n), // content: Text( // "Error logging in using email, please check your credentials.\nError: ${exception ?? 'Unknown'}"), // actions: [ // TextButton( // child: Text('Dismiss'.i18n), // onPressed: () { // Navigator.of(context).pop(); // }, // ) // ], // )); // } // // @override // Widget build(BuildContext context) { // return AlertDialog( // title: Text('Email Login'.i18n), // content: AutofillGroup( // child: Column( // mainAxisSize: MainAxisSize.min, // children: [ // TextFormField( // enabled: !_loading, // decoration: InputDecoration(labelText: 'Email'.i18n), // controller: _emailController, // autofillHints: const [ // AutofillHints.email, // ], // ), // const SizedBox(height: 8.0), // TextFormField( // enabled: !_loading, // obscureText: true, // decoration: InputDecoration(labelText: "Password".i18n), // controller: _passwordController, // autofillHints: const [ // AutofillHints.password, // ], // ) // ], // ), // ), // actions: [ // TextButton( // onPressed: _loading // ? null // : () async { // if (_emailController.text.isNotEmpty && // _passwordController.text.isNotEmpty) { // await _login(); // } else { // ScaffoldMessenger.of(context) // .snack("Missing email or password!".i18n); // } // }, // child: _loading // ? const CircularProgressIndicator() // : const Text('Login'), // ) // ], // ); // } // }