diff --git a/lib/client/client_account.dart b/lib/client/client_account.dart index c5639f3f..42b8a4ec 100644 --- a/lib/client/client_account.dart +++ b/lib/client/client_account.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'package:flutter/material.dart'; import 'package:flutter_triple/flutter_triple.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; @@ -21,6 +22,14 @@ class TwitterAccount { static final Map>> _rateLimits = {}; static final List _twitterTokenToDeleteLst = []; + static BuildContext? _currentContext; + static String? _currentLanguageCode; + + static void setCurrentContext(BuildContext currentContext) { + _currentContext = currentContext; + _currentLanguageCode = Localizations.localeOf(currentContext).languageCode; + } + static int nbrGuestAccounts() { return _twitterTokenLst.where((e) => e.guest).length; } @@ -169,7 +178,7 @@ class TwitterAccount { TwitterTokenEntity? tte = _twitterTokenLst.firstWhereOrNull((e) => e.profile != null && e.profile!.username == tpe.username); if (tte != null) { if (DateTime.now().difference(tte.createdAt).inDays >= 30) { - TwitterTokenEntity newTte = await TwitterRegularAccount.createRegularTwitterToken(tpe.username, tpe.password, tpe.name, tpe.email, tpe.phone); + TwitterTokenEntity newTte = await TwitterRegularAccount.createRegularTwitterToken(_currentContext, _currentLanguageCode, tpe.username, tpe.password, tpe.name, tpe.email, tpe.phone); addTwitterToken(newTte); markTwitterTokenForDeletion(tte); } @@ -360,7 +369,7 @@ class TwitterAccount { static Future createRegularTwitterToken(String username, String password, String? name, String? email, String? phone) async { TwitterTokenEntity? oldTte = _twitterTokenLst.firstWhereOrNull((e) => !e.guest && e.screenName == username); - TwitterTokenEntity newTte = await TwitterRegularAccount.createRegularTwitterToken(username, password, name, email, phone); + TwitterTokenEntity newTte = await TwitterRegularAccount.createRegularTwitterToken(_currentContext, _currentLanguageCode, username, password, name, email, phone); if (oldTte != null) { markTwitterTokenForDeletion(oldTte); await deleteTwitterTokensMarkedForDeletion(); diff --git a/lib/client/client_regular_account.dart b/lib/client/client_regular_account.dart index 40ed2f9a..7b0ed413 100644 --- a/lib/client/client_regular_account.dart +++ b/lib/client/client_regular_account.dart @@ -1,19 +1,21 @@ import 'dart:convert'; -import 'package:squawker/database/entities.dart'; +import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:logging/logging.dart'; import 'package:squawker/client/client_account.dart'; +import 'package:squawker/database/entities.dart'; +import 'package:squawker/generated/l10n.dart'; class TwitterRegularAccount { static final log = Logger('TwitterRegularAccount'); static Future _getLoginFlowToken(Map headers, String accessToken, String guestToken) async { - log.info('Posting https://api.twitter.com/1.1/onboarding/task.json?flow_name=login&api_version=1&known_device_token=&sim_country_code=us'); + log.info('Posting https://api.twitter.com/1.1/onboarding/task.json?flow_name=login'); headers.addAll({ 'Authorization': 'Bearer $accessToken', 'X-Guest-Token': guestToken }); - var response = await http.post(Uri.parse('https://api.twitter.com/1.1/onboarding/task.json?flow_name=login&api_version=1&known_device_token=&sim_country_code=us'), + var response = await http.post(Uri.parse('https://api.twitter.com/1.1/onboarding/task.json?flow_name=login'), headers: headers, body: json.encode({ 'flow_token': null, @@ -107,9 +109,13 @@ class TwitterRegularAccount { throw TwitterAccountException('Unable to get the password flow token. The response (${response.statusCode}) from Twitter/X was: ${response.body}'); } - static Future> _getDuplicationCheckFlowToken(Map headers, String flowToken) async { - log.info('Posting (duplication check) https://api.twitter.com/1.1/onboarding/task.json'); - var response = await http.post(Uri.parse('https://api.twitter.com/1.1/onboarding/task.json'), + static Future> _getDuplicationCheckFlowToken(Map headers, String flowToken, String? languageCode) async { + String url = 'https://api.twitter.com/1.1/onboarding/task.json'; + if (languageCode != null) { + url = '$url?lang=$languageCode'; + } + log.info('Posting (duplication check) $url'); + var response = await http.post(Uri.parse(url), headers: headers, body: json.encode({ 'flow_token': flowToken, @@ -147,16 +153,58 @@ class TwitterRegularAccount { throw TwitterAccountException('Unable to get the duplication check flow token. The response (${response.statusCode}) from Twitter/X was: ${response.body}'); } - static Future createRegularTwitterToken(String username, String password, String? name, String? email, String? phone) async { + static Future> _get2FAFlowToken(Map headers, String flowToken, String code) async { + log.info('Posting (2FA) https://api.twitter.com/1.1/onboarding/task.json'); + var response = await http.post(Uri.parse('https://api.twitter.com/1.1/onboarding/task.json'), + headers: headers, + body: json.encode({ + 'flow_token': flowToken, + 'subtask_inputs': [ + { + 'enter_text': { + 'text': code, + 'link': 'next_link' + }, + 'subtask_id': 'LoginTwoFactorAuthChallenge' + } + ] + }) + ); + + if (response.statusCode == 200) { + var result = jsonDecode(response.body); + return result; + } + + throw TwitterAccountException('Unable to get the 2FA flow token. The response (${response.statusCode}) from Twitter/X was: ${response.body}'); + } + + static Future createRegularTwitterToken(BuildContext? context, String? languageCode, String username, String password, String? name, String? email, String? phone) async { String accessToken = await TwitterAccount.getAccessToken(); String guestToken = await TwitterAccount.getGuestToken(accessToken); Map headers = TwitterAccount.initHeaders(); String flowToken = await _getLoginFlowToken(headers, accessToken, guestToken); flowToken = await _getUsernameFlowToken(headers, flowToken, username); flowToken = await _getPasswordFlowToken(headers, flowToken, password); - Map res = await _getDuplicationCheckFlowToken(headers, flowToken); - if (res['subtasks']?[0]?['open_account'] != null) { - Map openAccount = res['subtasks'][0]['open_account'] as Map; + Map res = await _getDuplicationCheckFlowToken(headers, flowToken, languageCode); + Map? openAccount; + if (res['subtasks']?[0]?['subtask_id'] == 'LoginTwoFactorAuthChallenge') { + if (context != null) { + flowToken = res['flow_token']; + Map head = res['subtasks'][0]['enter_text']['header']; + String? code = await askForTwoFactorCode(context, head['primary_text']['text'] as String, head['secondary_text']['text'] as String); + if (code != null) { + res = await _get2FAFlowToken(headers, flowToken, code); + if (res['subtasks']?[0]?['subtask_id'] == 'LoginSuccessSubtask') { + openAccount = res['subtasks'][0]['open_account'] as Map; + } + } + } + } + else if (res['subtasks']?[0]?['subtask_id'] == 'LoginSuccessSubtask') { + openAccount = res['subtasks'][0]['open_account'] as Map; + } + if (openAccount != null) { TwitterTokenEntity tte = TwitterTokenEntity( guest: false, idStr: (openAccount['user'] as Map)['id_str'] as String, @@ -172,5 +220,47 @@ class TwitterRegularAccount { } throw TwitterAccountException('Unable to create the regular Twitter/X token. The response from Twitter/X was: $res'); } + + static Future askForTwoFactorCode(BuildContext context, String primaryText, String secondaryText) async { + String? code; + return await showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(primaryText), + titleTextStyle: TextStyle(fontSize: Theme.of(context).textTheme.titleMedium!.fontSize, color: Theme.of(context).textTheme.titleMedium!.color, fontWeight: FontWeight.bold), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(secondaryText, style: TextStyle(fontSize: Theme.of(context).textTheme.labelMedium!.fontSize)), + SizedBox(height: 20), + TextField( + decoration: InputDecoration(contentPadding: EdgeInsets.all(5)), + onChanged: (value) async { + code = value; + }, + ) + ] + ), + actionsAlignment: MainAxisAlignment.center, + actions: [ + ElevatedButton( + child: Text(L10n.current.cancel), + onPressed: () { + Navigator.of(context).pop(null); + }, + ), + ElevatedButton( + child: Text(L10n.current.ok), + onPressed: () { + Navigator.of(context).pop(code); + }, + ), + ], + ); + } + ); + } } diff --git a/lib/generated/intl/messages_zh_Hans.dart b/lib/generated/intl/messages_zh_Hans.dart index fdecbd2c..31783caf 100644 --- a/lib/generated/intl/messages_zh_Hans.dart +++ b/lib/generated/intl/messages_zh_Hans.dart @@ -115,6 +115,8 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("数据导入成功"), "date_created": MessageLookupByLibrary.simpleMessage("创建日期"), "date_subscribed": MessageLookupByLibrary.simpleMessage("订阅日期"), + "default_subscription_tab": + MessageLookupByLibrary.simpleMessage("关注页默认标签"), "default_tab": MessageLookupByLibrary.simpleMessage("默认页面"), "delete": MessageLookupByLibrary.simpleMessage("删除"), "disable_screenshots": MessageLookupByLibrary.simpleMessage("禁用截屏"), @@ -199,11 +201,16 @@ class MessageLookup extends MessageLookupByLibrary { "include_replies": MessageLookupByLibrary.simpleMessage("包括回复"), "include_retweets": MessageLookupByLibrary.simpleMessage("包括转推"), "joined": m8, + "keep_feed_offset_description": + MessageLookupByLibrary.simpleMessage("应用重启时,会保持时间轴的滚动位置不变"), + "keep_feed_offset_label": + MessageLookupByLibrary.simpleMessage("从上次的位置继续浏览时间轴"), "language": MessageLookupByLibrary.simpleMessage("语言"), "language_subtitle": MessageLookupByLibrary.simpleMessage("需要重启应用"), "large": MessageLookupByLibrary.simpleMessage("大"), "leaner_feeds_description": MessageLookupByLibrary.simpleMessage("预览链接不显示在来自源的推文中"), + "leaner_feeds_label": MessageLookupByLibrary.simpleMessage("时间轴紧凑模式"), "legacy_android_import": MessageLookupByLibrary.simpleMessage("从旧的 Android 设备导入"), "let_the_developers_know_if_something_is_broken": @@ -419,8 +426,11 @@ class MessageLookup extends MessageLookupByLibrary { MessageLookupByLibrary.simpleMessage("是否隐藏被标记为敏感的推文"), "which_tab_is_shown_when_the_app_opens": MessageLookupByLibrary.simpleMessage("打开应用时显示哪个页面"), + "which_tab_is_shown_when_the_subscription_opens": + MessageLookupByLibrary.simpleMessage("打开关注页时展示哪个标签"), "would_you_like_to_enable_automatic_error_reporting": MessageLookupByLibrary.simpleMessage("您希望自动发送错误报告吗?"), + "x_api": MessageLookupByLibrary.simpleMessage("X API"), "yes": MessageLookupByLibrary.simpleMessage("好"), "yes_please": MessageLookupByLibrary.simpleMessage("是,请让我看"), "you_have_not_saved_any_tweets_yet": diff --git a/lib/group/_feed.dart b/lib/group/_feed.dart index e9257d3a..c808c21b 100644 --- a/lib/group/_feed.dart +++ b/lib/group/_feed.dart @@ -417,6 +417,7 @@ class SubscriptionGroupFeedState extends State with Widge @override Widget build(BuildContext context) { + TwitterAccount.setCurrentContext(context); BasePrefService prefs = PrefService.of(context, listen: false); _keepFeedOffset = prefs.get(optionKeepFeedOffset); diff --git a/lib/profile/_tweets.dart b/lib/profile/_tweets.dart index adc67ea4..f66d5a9b 100644 --- a/lib/profile/_tweets.dart +++ b/lib/profile/_tweets.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:pref/pref.dart'; import 'package:squawker/client/client.dart'; +import 'package:squawker/client/client_account.dart'; import 'package:squawker/constants.dart'; import 'package:squawker/profile/profile.dart'; import 'package:squawker/tweet/conversation.dart'; @@ -80,6 +81,7 @@ class _ProfileTweetsState extends State with AutomaticKeepAliveCl @override Widget build(BuildContext context) { super.build(context); + TwitterAccount.setCurrentContext(context); return Consumer(builder: (context, model, child) { if (model.hideSensitive && (widget.user.possiblySensitive ?? false)) { diff --git a/lib/profile/profile.dart b/lib/profile/profile.dart index 7d22fc37..62485a34 100644 --- a/lib/profile/profile.dart +++ b/lib/profile/profile.dart @@ -3,6 +3,7 @@ import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_triple/flutter_triple.dart'; +import 'package:squawker/client/client_account.dart'; import 'package:squawker/constants.dart'; import 'package:squawker/database/entities.dart'; import 'package:squawker/generated/l10n.dart'; @@ -235,6 +236,7 @@ class _ProfileScreenBodyState extends State with TickerProvid @override Widget build(BuildContext context) { + TwitterAccount.setCurrentContext(context); // TODO: This shouldn't happen before the profile is loaded var user = widget.profile.user; if (user.idStr == null) { diff --git a/lib/search/search.dart b/lib/search/search.dart index dd6e62f9..a39bbc98 100644 --- a/lib/search/search.dart +++ b/lib/search/search.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_triple/flutter_triple.dart'; import 'package:squawker/client/client.dart'; +import 'package:squawker/client/client_account.dart'; import 'package:squawker/constants.dart'; import 'package:squawker/database/entities.dart'; import 'package:squawker/generated/l10n.dart'; @@ -74,6 +75,7 @@ class _SearchScreenState extends State<_SearchScreen> with SingleTickerProviderS @override Widget build(BuildContext context) { + TwitterAccount.setCurrentContext(context); var subscriptionsModel = context.read(); var prefs = PrefService.of(context, listen: false); diff --git a/lib/settings/_account.dart b/lib/settings/_account.dart index e8589664..36abe143 100644 --- a/lib/settings/_account.dart +++ b/lib/settings/_account.dart @@ -3,6 +3,7 @@ import 'package:flutter_swipe_action_cell/flutter_swipe_action_cell.dart'; import 'package:logging/logging.dart'; import 'package:pref/pref.dart'; import 'package:squawker/client/client_account.dart'; +import 'package:squawker/client/client_regular_account.dart'; import 'package:squawker/database/entities.dart'; import 'package:squawker/generated/l10n.dart'; @@ -23,14 +24,11 @@ class _SettingsAccountFragmentState extends State { void initState() { super.initState(); _regularAccountsTokens = TwitterAccount.getRegularAccountsTokens(); - /* - _regularAccountsTokens.add(TwitterTokenEntity(guest: false, idStr: '666', screenName: 'toto', oauthToken: 'oooo', oauthTokenSecret: 'iiii', createdAt: DateTime.now(), - profile: TwitterProfileEntity(username: 'toto', password: '1234', createdAt: DateTime.now(), name: 'The Toto', email: 'allo@because.com'))); - */ } @override Widget build(BuildContext context) { + TwitterAccount.setCurrentContext(context); int nbrGuestAccounts = TwitterAccount.nbrGuestAccounts(); return Scaffold( appBar: AppBar(title: Text(L10n.current.account)), @@ -45,7 +43,7 @@ class _SettingsAccountFragmentState extends State { title: Text(L10n.current.regular_accounts(_regularAccountsTokens.length)), child: Icon(Icons.add_outlined), onTap: () async { - var result = await showDialog( + var result = await showDialog( barrierDismissible: false, context: context, builder: (BuildContext context) { @@ -55,31 +53,10 @@ class _SettingsAccountFragmentState extends State { ); } ); - if (result != null) { - try { - await TwitterAccount.createRegularTwitterToken(result['username'], result['password'], result['name'], result['email'], result['phone']); - setState(() { - _regularAccountsTokens = TwitterAccount.getRegularAccountsTokens(); - }); - } - catch (e, _) { - await showDialog( - barrierDismissible: false, - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: Text(L10n.current.error_from_twitter), - content: Text(e.toString()), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: Text(L10n.current.ok), - ), - ] - ); - } - ); - } + if (result != null && result) { + setState(() { + _regularAccountsTokens = TwitterAccount.getRegularAccountsTokens(); + }); } }, ), @@ -166,6 +143,7 @@ class _AddAccountDialogState extends State { @override Widget build(BuildContext context) { + TwitterAccount.setCurrentContext(context); double width = MediaQuery.of(context).size.width; return Container( padding: EdgeInsets.all(20), @@ -295,7 +273,7 @@ class _AddAccountDialogState extends State { children: [ ElevatedButton( child: Text(L10n.current.cancel), - onPressed: () => Navigator.pop(context, null), + onPressed: () => Navigator.pop(context, false), ), SizedBox(width: 20), ElevatedButton( @@ -304,13 +282,29 @@ class _AddAccountDialogState extends State { if (!_saveEnabled) { return; } - Navigator.pop(context, { - 'username': _username, - 'password': _password, - 'name': _name, - 'email': _email, - 'phone': _phone - }); + try { + await TwitterAccount.createRegularTwitterToken(_username, _password, _name, _email, _phone); + Navigator.pop(context, true); + } + catch (e, _) { + await showDialog( + barrierDismissible: false, + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(L10n.current.error_from_twitter), + content: Text(e.toString()), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(L10n.current.ok), + ), + ] + ); + } + ); + Navigator.pop(context, false); + } } ), ] diff --git a/lib/status.dart b/lib/status.dart index 99f5fbd1..9fe40299 100644 --- a/lib/status.dart +++ b/lib/status.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:squawker/client/client.dart'; +import 'package:squawker/client/client_account.dart'; import 'package:squawker/constants.dart'; import 'package:squawker/generated/l10n.dart'; import 'package:squawker/profile/profile.dart'; @@ -102,6 +103,7 @@ class _StatusScreenState extends State<_StatusScreen> { @override Widget build(BuildContext context) { + TwitterAccount.setCurrentContext(context); return Scaffold( appBar: AppBar(), body: ChangeNotifierProvider( diff --git a/lib/trends/_tabs.dart b/lib/trends/_tabs.dart index c2884c5f..5fdb19bd 100644 --- a/lib/trends/_tabs.dart +++ b/lib/trends/_tabs.dart @@ -1,6 +1,7 @@ import 'package:dart_twitter_api/api/trends/data/trend_location.dart'; import 'package:flutter/material.dart'; import 'package:flutter_triple/flutter_triple.dart'; +import 'package:squawker/client/client_account.dart'; import 'package:squawker/generated/l10n.dart'; import 'package:squawker/trends/trends_model.dart'; import 'package:provider/provider.dart'; @@ -62,6 +63,7 @@ class _TrendsTabBarState extends State with TickerProviderStateMix @override Widget build(BuildContext context) { + TwitterAccount.setCurrentContext(context); var model = context.read(); return ScopedBuilder(