V4.6.4 bug fixes (#922)

* Fix Concurrent modification exception * Fix minor UI issues * Change onramper crypto asset name for Litcoin * Fix secure storage issue, fetching password/PIN with null * - Fix Navigation issue while keyboard is displaying - Remove deprecated screen * Take currency From/To info from our trade not the returned one * Fix anon pay fields UI * Fix Anonpay border/icons UI * Add extra padding in QR image as a safe layer * Generalize ignored connection error * Remove Bio Auth option from desktop * Fix some Transaction info not parsed correctly

Omar Hatem committed May 10, 2023 at 16:58 UTC 1a3d47748dd89fbb323d00a36b04198bc8a6b566
16 files changed +112 -156
cw_bitcoin/lib/electrum.dart
+1 -1
@@ -88,7 +88,7 @@ class ElectrumClient {
88 unterminatedString = '';
89 }
90 } on TypeError catch (e) {
91 - if (!e.toString().contains('Map<String, Object>') || !e.toString().contains('Map<String, dynamic>')) {
91 + if (!e.toString().contains('Map<String, Object>') && !e.toString().contains('Map<String, dynamic>')) {
92 return;
93 }
94
lib/buy/onramper/onramper_buy_provider.dart
+12 -2
@@ -1,6 +1,7 @@
1 import 'package:cake_wallet/.secrets.g.dart' as secrets;
2 import 'package:cake_wallet/store/settings_store.dart';
3 import 'package:cake_wallet/themes/theme_base.dart';
4 +import 'package:cw_core/crypto_currency.dart';
5 import 'package:cw_core/wallet_base.dart';
6
7 class OnRamperBuyProvider {
@@ -13,7 +14,16 @@ class OnRamperBuyProvider {
14
15 static const _baseUrl = 'buy.onramper.com';
16
16 - static String get _apiKey => secrets.onramperApiKey;
17 + String get _apiKey => secrets.onramperApiKey;
18 +
19 + String get _normalizeCryptoCurrency {
20 + switch (_wallet.currency) {
21 + case CryptoCurrency.ltc:
22 + return "LTC_LITECOIN";
23 + default:
24 + return _wallet.currency.title;
25 + }
26 + }
27
28 Uri requestUrl() {
29 String primaryColor,
@@ -53,7 +63,7 @@ class OnRamperBuyProvider {
63
64 return Uri.https(_baseUrl, '', <String, dynamic>{
65 'apiKey': _apiKey,
56 - 'defaultCrypto': _wallet.currency.title,
66 + 'defaultCrypto': _normalizeCryptoCurrency,
67 'defaultFiat': _settingsStore.fiatCurrency.title,
68 'wallets': '${_wallet.currency.title}:${_wallet.walletAddresses.address}',
69 'supportSell': "false",
lib/router.dart
+5 -1
@@ -169,6 +169,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
169 fullscreenDialog: true);
170 } else if (isSingleCoin) {
171 return MaterialPageRoute<void>(
172 + fullscreenDialog: true,
173 builder: (_) => getIt.get<WalletRestorePage>(
174 param1: availableWalletTypes.first
175 ));
@@ -188,6 +189,7 @@ Route<dynamic> createRoute(RouteSettings settings) {
189
190 case Routes.restoreWallet:
191 return MaterialPageRoute<void>(
192 + fullscreenDialog: true,
193 builder: (_) => getIt.get<WalletRestorePage>(
194 param1: settings.arguments as WalletType));
195
@@ -509,7 +511,9 @@ Route<dynamic> createRoute(RouteSettings settings) {
511
512 case Routes.anonPayInvoicePage:
513 final args = settings.arguments as List;
512 - return CupertinoPageRoute<void>(builder: (_) => getIt.get<AnonPayInvoicePage>(param1: args));
514 + return CupertinoPageRoute<void>(
515 + fullscreenDialog: true,
516 + builder: (_) => getIt.get<AnonPayInvoicePage>(param1: args));
517
518 case Routes.anonPayReceivePage:
519 final anonInvoiceViewData = settings.arguments as AnonpayInfoBase;
lib/src/screens/dashboard/widgets/address_page.dart
+4 -1
@@ -96,7 +96,10 @@ class AddressPage extends BasePage {
96
97 @override
98 Widget middle(BuildContext context) =>
99 - PresentReceiveOptionPicker(receiveOptionViewModel: receiveOptionViewModel);
99 + PresentReceiveOptionPicker(
100 + receiveOptionViewModel: receiveOptionViewModel,
101 + hasWhiteBackground: currentTheme.type == ThemeType.light,
102 + );
103
104 @override
105 Widget Function(BuildContext, Widget) get rootWrapper =>
lib/src/screens/dashboard/widgets/present_receive_option_picker.dart
+13 -5
@@ -9,14 +9,22 @@ import 'package:flutter_mobx/flutter_mobx.dart';
9 import 'package:cake_wallet/generated/i18n.dart';
10
11 class PresentReceiveOptionPicker extends StatelessWidget {
12 - PresentReceiveOptionPicker({required this.receiveOptionViewModel});
12 + PresentReceiveOptionPicker(
13 + {required this.receiveOptionViewModel, this.hasWhiteBackground = false});
14
15 final ReceiveOptionViewModel receiveOptionViewModel;
16 + final bool hasWhiteBackground;
17
18 @override
19 Widget build(BuildContext context) {
18 - final arrowBottom =
19 - Image.asset('assets/images/arrow_bottom_purple_icon.png', color: Colors.white, height: 6);
20 + final textIconTheme = hasWhiteBackground
21 + ? Theme.of(context).accentTextTheme.headline2!.backgroundColor!
22 + : Colors.white;
23 + final arrowBottom = Image.asset(
24 + 'assets/images/arrow_bottom_purple_icon.png',
25 + color: textIconTheme,
26 + height: 6,
27 + );
28
29 return TextButton(
30 onPressed: () => _showPicker(context),
@@ -40,14 +48,14 @@ class PresentReceiveOptionPicker extends StatelessWidget {
48 fontSize: 18.0,
49 fontWeight: FontWeight.bold,
50 fontFamily: 'Lato',
43 - color: Theme.of(context).accentTextTheme.headline2!.backgroundColor!),
51 + color: textIconTheme),
52 ),
53 Observer(
54 builder: (_) => Text(receiveOptionViewModel.selectedReceiveOption.toString(),
55 style: TextStyle(
56 fontSize: 10.0,
57 fontWeight: FontWeight.w500,
50 - color: Theme.of(context).textTheme.headline5!.color!)))
58 + color: textIconTheme)))
59 ],
60 ),
61 SizedBox(width: 5),
lib/src/screens/receive/widgets/anonpay_currency_input_field.dart
+1 -1
@@ -32,7 +32,7 @@ class AnonpayCurrencyInputField extends StatelessWidget {
32 decoration: BoxDecoration(
33 border: Border(
34 bottom: BorderSide(
35 - color: Theme.of(context).accentTextTheme.headline6!.backgroundColor!,
35 + color: Theme.of(context).primaryTextTheme.bodyText1!.color!,
36 width: 1)),
37 ),
38 child: Padding(
lib/src/screens/receive/widgets/anonpay_input_form.dart
+3 -3
@@ -69,7 +69,7 @@ class AnonInvoiceForm extends StatelessWidget {
69 BaseTextFormField(
70 controller: nameController,
71 focusNode: _nameFocusNode,
72 - borderColor: Theme.of(context).accentTextTheme.headline6!.backgroundColor,
72 + borderColor: Theme.of(context).primaryTextTheme.bodyText1!.color!,
73 suffixIcon: SizedBox(width: 36),
74 hintText: S.of(context).optional_name,
75 textInputAction: TextInputAction.next,
@@ -88,7 +88,7 @@ class AnonInvoiceForm extends StatelessWidget {
88 controller: descriptionController,
89 focusNode: _descriptionFocusNode,
90 textInputAction: TextInputAction.next,
91 - borderColor: Theme.of(context).accentTextTheme.headline6!.backgroundColor,
91 + borderColor: Theme.of(context).primaryTextTheme.bodyText1!.color!,
92 suffixIcon: SizedBox(width: 36),
93 hintText: S.of(context).optional_description,
94 placeholderTextStyle: TextStyle(
@@ -104,7 +104,7 @@ class AnonInvoiceForm extends StatelessWidget {
104 controller: emailController,
105 textInputAction: TextInputAction.next,
106 focusNode: _emailFocusNode,
107 - borderColor: Theme.of(context).accentTextTheme.headline6!.backgroundColor,
107 + borderColor: Theme.of(context).primaryTextTheme.bodyText1!.color!,
108 suffixIcon: SizedBox(width: 36),
109 keyboardType: TextInputType.emailAddress,
110 hintText: S.of(context).optional_email_hint,
lib/src/screens/receive/widgets/currency_input_field.dart
+10 -8
@@ -1,4 +1,3 @@
1 -import 'package:cake_wallet/core/amount_validator.dart';
1 import 'package:cake_wallet/src/widgets/base_text_form_field.dart';
2 import 'package:cw_core/currency.dart';
3 import 'package:flutter/material.dart';
@@ -10,18 +9,20 @@ class CurrencyInputField extends StatelessWidget {
9 required this.onTapPicker,
10 required this.selectedCurrency,
11 this.focusNode,
13 - required this.controller,
12 + required this.controller, required this.isLight,
13 });
14 +
15 final Function() onTapPicker;
16 final Currency selectedCurrency;
17 final FocusNode? focusNode;
18 final TextEditingController controller;
19 + final bool isLight;
20
21 @override
22 Widget build(BuildContext context) {
23 final arrowBottomPurple = Image.asset(
24 'assets/images/arrow_bottom_purple_icon.png',
24 - color: Colors.white,
25 + color: Theme.of(context).accentTextTheme.headline2!.backgroundColor!,
26 height: 8,
27 );
28 final _width = MediaQuery.of(context).size.width;
@@ -38,14 +39,14 @@ class CurrencyInputField extends StatelessWidget {
39 keyboardType: TextInputType.numberWithOptions(signed: false, decimal: true),
40 inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+(\.|\,)?\d{0,8}'))],
41 hintText: '0.000',
41 - placeholderTextStyle: TextStyle(
42 + placeholderTextStyle: isLight ? null : TextStyle(
43 color: Theme.of(context).primaryTextTheme.headline5!.color!,
44 fontWeight: FontWeight.w600,
45 ),
46 borderColor: Theme.of(context).accentTextTheme.headline6!.backgroundColor!,
46 - textColor: Colors.white,
47 + textColor: Theme.of(context).accentTextTheme.headline2!.backgroundColor!,
48 textStyle: TextStyle(
48 - color: Colors.white,
49 + color: Theme.of(context).accentTextTheme.headline2!.backgroundColor!,
50 ),
51 prefixIcon: Padding(
52 padding: EdgeInsets.only(
@@ -68,7 +69,7 @@ class CurrencyInputField extends StatelessWidget {
69 style: TextStyle(
70 fontWeight: FontWeight.w600,
71 fontSize: 16,
71 - color: Colors.white,
72 + color: Theme.of(context).accentTextTheme.headline2!.backgroundColor!,
73 ),
74 ),
75 if (selectedCurrency.tag != null)
@@ -103,7 +104,8 @@ class CurrencyInputField extends StatelessWidget {
104 style: TextStyle(
105 fontWeight: FontWeight.w600,
106 fontSize: 20,
106 - color: Colors.white,
107 + color:
108 + Theme.of(context).accentTextTheme.headline2!.backgroundColor!,
109 ),
110 ),
111 ),
lib/src/screens/receive/widgets/qr_image.dart
+1 -1
@@ -23,7 +23,7 @@ class QrImage extends StatelessWidget {
23 size: size,
24 foregroundColor: Colors.black,
25 backgroundColor: Colors.white,
26 - padding: EdgeInsets.zero,
26 + padding: const EdgeInsets.all(8.0),
27 );
28 }
29 }
lib/src/screens/receive/widgets/qr_widget.dart
+1
@@ -119,6 +119,7 @@ class QRWidget extends StatelessWidget {
119 controller: amountController,
120 onTapPicker: () => _presentPicker(context),
121 selectedCurrency: addressListViewModel.selectedCurrency,
122 + isLight: isLight,
123 ),
124 ),
125 ),
lib/src/screens/restore/restore_from_keys.dart deleted
-83
@@ -1,83 +0,0 @@
1 -import 'package:flutter/services.dart';
2 -import 'package:flutter/cupertino.dart';
3 -import 'package:flutter/material.dart';
4 -import 'package:cake_wallet/generated/i18n.dart';
5 -import 'package:cake_wallet/src/widgets/blockchain_height_widget.dart';
6 -import 'package:cake_wallet/src/widgets/base_text_form_field.dart';
7 -
8 -class RestoreFromKeysFrom extends StatefulWidget {
9 - @override
10 - _RestoreFromKeysFromState createState() => _RestoreFromKeysFromState();
11 -}
12 -
13 -class _RestoreFromKeysFromState extends State<RestoreFromKeysFrom> {
14 - final _formKey = GlobalKey<FormState>();
15 - final _blockchainHeightKey = GlobalKey<BlockchainHeightState>();
16 - final _nameController = TextEditingController();
17 - final _addressController = TextEditingController();
18 - final _viewKeyController = TextEditingController();
19 - final _spendKeyController = TextEditingController();
20 - final _wifController = TextEditingController();
21 -
22 - @override
23 - void initState() {
24 - // _nameController.addListener(() =>
25 - // widget.walletRestorationFromKeysVM.name = _nameController.text);
26 - // _addressController.addListener(() =>
27 - // widget.walletRestorationFromKeysVM.address = _addressController.text);
28 - // _viewKeyController.addListener(() =>
29 - // widget.walletRestorationFromKeysVM.viewKey = _viewKeyController.text);
30 - // _spendKeyController.addListener(() =>
31 - // widget.walletRestorationFromKeysVM.spendKey = _spendKeyController.text);
32 - // _wifController.addListener(() =>
33 - // widget.walletRestorationFromKeysVM.wif = _wifController.text);
34 -
35 - super.initState();
36 - }
37 -
38 - @override
39 - void dispose() {
40 - _nameController.dispose();
41 - _addressController.dispose();
42 - _viewKeyController.dispose();
43 - _spendKeyController.dispose();
44 - _wifController.dispose();
45 - super.dispose();
46 - }
47 -
48 - @override
49 - Widget build(BuildContext context) {
50 - return Container(
51 - padding: EdgeInsets.only(left: 24, right: 24),
52 - child: Form(
53 - key: _formKey,
54 - child: Column(children: <Widget>[
55 - BaseTextFormField(
56 - controller: _addressController,
57 - keyboardType: TextInputType.multiline,
58 - maxLines: null,
59 - hintText: S.of(context).restore_address,
60 - ),
61 - Container(
62 - padding: EdgeInsets.only(top: 20.0),
63 - child: BaseTextFormField(
64 - controller: _viewKeyController,
65 - hintText: S.of(context).restore_view_key_private,
66 - )),
67 - Container(
68 - padding: EdgeInsets.only(top: 20.0),
69 - child: BaseTextFormField(
70 - controller: _spendKeyController,
71 - hintText: S.of(context).restore_spend_key_private,
72 - )),
73 - BlockchainHeightWidget(
74 - key: _blockchainHeightKey,
75 - onHeightChange: (height) {
76 - // widget.walletRestorationFromKeysVM.height = height;
77 - print(height);
78 - }),
79 - ]),
80 - ),
81 - );
82 - }
83 -}
lib/src/screens/settings/security_backup_page.dart
+22 -20
@@ -8,6 +8,7 @@ import 'package:cake_wallet/src/screens/settings/widgets/settings_cell_with_arro
8 import 'package:cake_wallet/src/screens/settings/widgets/settings_picker_cell.dart';
9 import 'package:cake_wallet/src/screens/settings/widgets/settings_switcher_cell.dart';
10 import 'package:cake_wallet/src/widgets/standard_list.dart';
11 +import 'package:cake_wallet/utils/device_info.dart';
12 import 'package:cake_wallet/view_model/settings/security_settings_view_model.dart';
13 import 'package:flutter/material.dart';
14 import 'package:flutter_mobx/flutter_mobx.dart';
@@ -48,29 +49,30 @@ class SecurityBackupPage extends BasePage {
49 ),
50 ),
51 StandardListSeparator(padding: EdgeInsets.symmetric(horizontal: 24)),
51 - Observer(builder: (_) {
52 - return SettingsSwitcherCell(
53 - title: S.current.settings_allow_biometrical_authentication,
54 - value: _securitySettingsViewModel.allowBiometricalAuthentication,
55 - onValueChange: (BuildContext context, bool value) {
56 - if (value) {
57 - _authService.authenticateAction(context,
58 - onAuthSuccess: (isAuthenticatedSuccessfully) async {
59 - if (isAuthenticatedSuccessfully) {
60 - if (await _securitySettingsViewModel.biometricAuthenticated()) {
52 + if (DeviceInfo.instance.isMobile)
53 + Observer(builder: (_) {
54 + return SettingsSwitcherCell(
55 + title: S.current.settings_allow_biometrical_authentication,
56 + value: _securitySettingsViewModel.allowBiometricalAuthentication,
57 + onValueChange: (BuildContext context, bool value) {
58 + if (value) {
59 + _authService.authenticateAction(context,
60 + onAuthSuccess: (isAuthenticatedSuccessfully) async {
61 + if (isAuthenticatedSuccessfully) {
62 + if (await _securitySettingsViewModel.biometricAuthenticated()) {
63 + _securitySettingsViewModel
64 + .setAllowBiometricalAuthentication(isAuthenticatedSuccessfully);
65 + }
66 + } else {
67 _securitySettingsViewModel
68 .setAllowBiometricalAuthentication(isAuthenticatedSuccessfully);
69 }
64 - } else {
65 - _securitySettingsViewModel
66 - .setAllowBiometricalAuthentication(isAuthenticatedSuccessfully);
67 - }
68 - });
69 - } else {
70 - _securitySettingsViewModel.setAllowBiometricalAuthentication(value);
71 - }
72 - });
73 - }),
70 + });
71 + } else {
72 + _securitySettingsViewModel.setAllowBiometricalAuthentication(value);
73 + }
74 + });
75 + }),
76 Observer(builder: (_) {
77 return SettingsPickerCell<PinCodeRequiredDuration>(
78 title: S.current.require_pin_after,
lib/utils/exception_handler.dart
+5 -4
@@ -141,9 +141,9 @@ class ExceptionHandler {
141 "errno = 103", // SocketException: Software caused connection abort
142 "errno = 104", // SocketException: Connection reset by peer
143 "errno = 110", // SocketException: Connection timed out
144 - "HttpException: Connection reset by peer",
145 - "HttpException: Connection closed before full header was received",
146 - "HandshakeException: Connection terminated during handshake",
144 + "Connection reset by peer",
145 + "Connection closed before full header was received",
146 + "Connection terminated during handshake",
147 "PERMISSION_NOT_GRANTED",
148 ];
149
@@ -172,7 +172,7 @@ class ExceptionHandler {
172 }
173
174 await file.writeAsString(
175 - "App Version: $currentVersion\n\nDevice Info $deviceInfo",
175 + "App Version: $currentVersion\n\nDevice Info $deviceInfo\n\n",
176 mode: FileMode.append,
177 );
178 }
@@ -193,6 +193,7 @@ class ExceptionHandler {
193 'systemVersion': data.systemVersion,
194 'model': data.model,
195 'localizedModel': data.localizedModel,
196 + 'isPhysicalDevice': data.isPhysicalDevice,
197 };
198 }
199
lib/view_model/exchange/exchange_trade_view_model.dart
+4 -4
@@ -123,8 +123,8 @@ abstract class ExchangeTradeViewModelBase with Store {
123 }
124
125 void _updateItems() {
126 - final tagFrom = trade.from.tag != null ? '${trade.from.tag}' + ' ' : '';
127 - final tagTo = trade.to.tag != null ? '${trade.to.tag}' + ' ' : '';
126 + final tagFrom = tradesStore.trade!.from.tag != null ? '${tradesStore.trade!.from.tag}' + ' ' : '';
127 + final tagTo = tradesStore.trade!.to.tag != null ? '${tradesStore.trade!.to.tag}' + ' ' : '';
128 items.clear();
129 items.add(ExchangeTradeItem(
130 title: "${trade.provider.title} ${S.current.id}", data: '${trade.id}', isCopied: true));
@@ -142,11 +142,11 @@ abstract class ExchangeTradeViewModelBase with Store {
142 items.addAll([
143 ExchangeTradeItem(title: S.current.amount, data: '${trade.amount}', isCopied: true),
144 ExchangeTradeItem(
145 - title: S.current.send_to_this_address('${trade.from}', tagFrom) + ':',
145 + title: S.current.send_to_this_address('${tradesStore.trade!.from}', tagFrom) + ':',
146 data: trade.inputAddress ?? '',
147 isCopied: true),
148 ExchangeTradeItem(
149 - title: S.current.arrive_in_this_address('${trade.to}', tagTo) + ':',
149 + title: S.current.arrive_in_this_address('${tradesStore.trade!.to}', tagTo) + ':',
150 data: trade.payoutAddress ?? '',
151 isCopied: true),
152 ]);
lib/view_model/exchange/exchange_view_model.dart
+28 -20
@@ -3,7 +3,6 @@ import 'dart:collection';
3 import 'dart:convert';
4
5 import 'package:cake_wallet/entities/exchange_api_mode.dart';
6 -import 'package:cake_wallet/entities/fiat_api_mode.dart';
6 import 'package:cake_wallet/entities/preferences_key.dart';
7 import 'package:cake_wallet/exchange/sideshift/sideshift_exchange_provider.dart';
8 import 'package:cake_wallet/exchange/sideshift/sideshift_request.dart';
@@ -244,7 +243,7 @@ abstract class ExchangeViewModelBase with Store {
243
244 List<CryptoCurrency> depositCurrencies;
245
247 - NumberFormat _cryptoNumberFormat;
246 + final NumberFormat _cryptoNumberFormat;
247
248 final SettingsStore _settingsStore;
249
@@ -388,27 +387,36 @@ abstract class ExchangeViewModelBase with Store {
387 double? lowestMin = double.maxFinite;
388 double? highestMax = 0.0;
389
391 - for (var provider in selectedProviders) {
392 - /// if this provider is not valid for the current pair, skip it
393 - if (!providersForCurrentPair().contains(provider)) {
394 - continue;
395 - }
390 + try {
391 + for (var provider in selectedProviders) {
392 + /// if this provider is not valid for the current pair, skip it
393 + if (!providersForCurrentPair().contains(provider)) {
394 + continue;
395 + }
396
397 - try {
398 - final tempLimits = await provider.fetchLimits(
399 - from: from,
400 - to: to,
401 - isFixedRateMode: isFixedRateMode);
397 + try {
398 + final tempLimits = await provider.fetchLimits(
399 + from: from,
400 + to: to,
401 + isFixedRateMode: isFixedRateMode);
402
403 - if (lowestMin != null && (tempLimits.min ?? -1) < lowestMin) {
404 - lowestMin = tempLimits.min;
405 - }
406 - if (highestMax != null && (tempLimits.max ?? double.maxFinite) > highestMax) {
407 - highestMax = tempLimits.max;
403 + if (lowestMin != null && (tempLimits.min ?? -1) < lowestMin) {
404 + lowestMin = tempLimits.min;
405 + }
406 + if (highestMax != null && (tempLimits.max ?? double.maxFinite) > highestMax) {
407 + highestMax = tempLimits.max;
408 + }
409 + } catch (e) {
410 + continue;
411 }
409 - } catch (e) {
410 - continue;
412 }
413 + } on ConcurrentModificationError {
414 + /// if user changed the selected providers while fetching limits
415 + /// then delay the fetching limits a bit and try again
416 + ///
417 + /// this is because the limitation of collections that
418 + /// you can't modify it while iterating through it
419 + Future.delayed(Duration(milliseconds: 200), loadLimits);
420 }
421
422 if (lowestMin != double.maxFinite) {
@@ -534,7 +542,7 @@ abstract class ExchangeViewModelBase with Store {
542 ///
543 /// this is because the limitation of the SplayTreeMap that
544 /// you can't modify it while iterating through it
537 - Future.delayed(Duration(milliseconds: 500), createTrade);
545 + Future.delayed(Duration(milliseconds: 200), createTrade);
546 }
547 }
548
pubspec_base.yaml
+2 -2
@@ -17,8 +17,8 @@ dependencies:
17 git:
18 url: https://github.com/cake-tech/flutter_secure_storage.git
19 path: flutter_secure_storage
20 - ref: cake-6.0.0
21 - version: 6.0.0
20 + ref: cake-8.0.0
21 + version: 8.0.0
22 # provider: ^6.0.3
23 rxdart: ^0.27.4
24 yaml: ^3.1.1