freezer/lib/ui/login_screen.dart
Pato05 87c9733f51
add build script for linux
fix audio service stop on android
getTrack backend improvements
get new track token when expired
move shuffle button into LibraryPlaylists as FAB
move favoriteButton next to track title
move lyrics button on top of album art
search: fix chips, and remove checkbox when selected
2024-02-19 00:49:32 +01:00

838 lines
29 KiB
Dart

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<LoginWidget> createState() => _LoginWidgetState();
}
class _LoginWidgetState extends State<LoginWidget> {
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: <Widget>[
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: <Widget>[
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: <Widget>[
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<LoginBrowser> createState() => _LoginBrowserState();
}
class _LoginBrowserState extends State<LoginBrowser> {
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<OtherDeviceLogin> createState() => _OtherDeviceLoginState();
}
class _OtherDeviceLoginState extends State<OtherDeviceLogin> {
late final HttpServer _server;
late final StreamSubscription _serverSubscription;
late final String? _deviceIP;
late final Future<void> _serverReady;
late int _code;
late Timer _codeTimer;
final _timerNotifier = ValueNotifier<double>(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<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 = <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);
},
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<EmailLogin> createState() => _EmailLoginState();
//}
//
//class _EmailLoginState extends State<EmailLogin> {
// 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'),
// )
// ],
// );
// }
// }