Count UTF8 bytes for byte-limited text fields.

This commit is contained in:
Dain Nilsson 2022-07-05 14:53:21 +02:00
parent 7ee5b82906
commit 662536140a
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
6 changed files with 41 additions and 4 deletions

View File

@ -10,6 +10,7 @@ import 'package:yubico_authenticator/app/logging.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_text_fields.dart';
import '../state.dart'; import '../state.dart';
import '../../fido/models.dart'; import '../../fido/models.dart';
import '../../app/models.dart'; import '../../app/models.dart';
@ -179,6 +180,8 @@ class _AddFingerprintDialogState extends ConsumerState<AddFingerprintDialog>
TextFormField( TextFormField(
focusNode: _nameFocus, focusNode: _nameFocus,
maxLength: 15, maxLength: 15,
inputFormatters: [limitBytesLength(15)],
buildCounter: buildCountersFor(_label),
autofocus: true, autofocus: true,
decoration: InputDecoration( decoration: InputDecoration(
enabled: _fingerprint != null, enabled: _fingerprint != null,

View File

@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_text_fields.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../../app/models.dart'; import '../../app/models.dart';
@ -69,8 +70,9 @@ class _RenameAccountDialogState extends ConsumerState<RenameFingerprintDialog> {
const Text('This will change the label of the fingerprint.'), const Text('This will change the label of the fingerprint.'),
TextFormField( TextFormField(
initialValue: _label, initialValue: _label,
// TODO: Make this field count UTF-8 bytes instead of characters.
maxLength: 15, maxLength: 15,
inputFormatters: [limitBytesLength(15)],
buildCounter: buildCountersFor(_label),
decoration: const InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: 'Label', labelText: 'Label',

View File

@ -5,14 +5,15 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:yubico_authenticator/app/logging.dart';
import '../../app/logging.dart';
import '../../app/message.dart'; import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../widgets/file_drop_target.dart'; import '../../widgets/file_drop_target.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_text_fields.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'utils.dart'; import 'utils.dart';
@ -210,6 +211,8 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
autofocus: true, autofocus: true,
enabled: issuerRemaining > 0, enabled: issuerRemaining > 0,
maxLength: max(issuerRemaining, 1), maxLength: max(issuerRemaining, 1),
inputFormatters: [limitBytesLength(issuerRemaining)],
buildCounter: buildCountersFor(_issuerController.text),
decoration: const InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: 'Issuer (optional)', labelText: 'Issuer (optional)',
@ -229,6 +232,8 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
key: const Key('name'), key: const Key('name'),
controller: _accountController, controller: _accountController,
maxLength: max(nameRemaining, 1), maxLength: max(nameRemaining, 1),
buildCounter: buildCountersFor(_accountController.text),
inputFormatters: [limitBytesLength(nameRemaining)],
decoration: const InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: 'Account name', labelText: 'Account name',

View File

@ -7,6 +7,7 @@ import '../../app/message.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../desktop/models.dart'; import '../../desktop/models.dart';
import '../../widgets/responsive_dialog.dart'; import '../../widgets/responsive_dialog.dart';
import '../../widgets/utf8_text_fields.dart';
import '../models.dart'; import '../models.dart';
import '../state.dart'; import '../state.dart';
import 'utils.dart'; import 'utils.dart';
@ -97,6 +98,8 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
initialValue: _issuer, initialValue: _issuer,
enabled: issuerRemaining > 0, enabled: issuerRemaining > 0,
maxLength: issuerRemaining > 0 ? issuerRemaining : null, maxLength: issuerRemaining > 0 ? issuerRemaining : null,
buildCounter: buildCountersFor(_issuer),
inputFormatters: [limitBytesLength(issuerRemaining)],
decoration: const InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: 'Issuer (optional)', labelText: 'Issuer (optional)',
@ -112,6 +115,8 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
TextFormField( TextFormField(
initialValue: _account, initialValue: _account,
maxLength: nameRemaining, maxLength: nameRemaining,
inputFormatters: [limitBytesLength(nameRemaining)],
buildCounter: buildCountersFor(_account),
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: 'Account name', labelText: 'Account name',

View File

@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math'; import 'dart:math';
import '../models.dart'; import '../models.dart';
@ -18,7 +19,7 @@ Pair<int, int> getRemainingKeySpace(
// Non-standard TOTP periods are stored as part of this data, as a "D/"- prefix. // Non-standard TOTP periods are stored as part of this data, as a "D/"- prefix.
remaining -= '$period/'.length; remaining -= '$period/'.length;
} }
int issuerSpace = issuer.length; int issuerSpace = utf8.encode(issuer).length;
if (issuer.isNotEmpty) { if (issuer.isNotEmpty) {
// Issuer is separated from name with a ":", if present. // Issuer is separated from name with a ":", if present.
issuerSpace += 1; issuerSpace += 1;
@ -26,7 +27,7 @@ Pair<int, int> getRemainingKeySpace(
return Pair( return Pair(
// Always reserve at least one character for name // Always reserve at least one character for name
remaining - 1 - max(name.length, 1), remaining - 1 - max(utf8.encode(name).length, 1),
remaining - issuerSpace, remaining - issuerSpace,
); );
} }

View File

@ -0,0 +1,21 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
int _byteLength(String value) => utf8.encode(value).length;
InputCounterWidgetBuilder buildCountersFor(String currentValue) =>
(context, {required currentLength, required isFocused, maxLength}) => Text(
maxLength != null ? '${_byteLength(currentValue)}/$maxLength' : '',
style: Theme.of(context).textTheme.caption,
);
TextInputFormatter limitBytesLength(int maxByteLength) =>
TextInputFormatter.withFunction((oldValue, newValue) {
final newLength = _byteLength(newValue.text);
if (newLength <= maxByteLength) {
return newValue;
}
return oldValue;
});