freezer/lib/ui/login_screen.dart

839 lines
30 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;
final deezerAPI = DeezerAPI.instance;
//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'),
// )
// ],
// );
// }
// }