Compare commits

...

34 Commits

Author SHA1 Message Date
vishnukvmd
e52aa78fae v1.0.17 2022-11-23 16:26:47 +05:30
vishnukvmd
9bcd107de8 Display a coach mark to nudge the user to swipe left 2022-11-23 16:26:29 +05:30
vishnukvmd
8e5488eaf9 v1.0.16 2022-11-22 15:00:08 +05:30
vishnukvmd
b20139f3b8 Provide option to opt in to crash analytics 2022-11-22 14:59:25 +05:30
vishnukvmd
21b139687d Disable Sentry tunnel 2022-11-22 14:58:59 +05:30
vishnukvmd
385731196f Remove code for setting anonymous user ID from Configuration 2022-11-22 14:44:27 +05:30
vishnukvmd
71c2958078 Improve warning while sharing logs 2022-11-22 14:06:46 +05:30
vishnukvmd
7a25da3927 Simplify email to share logs with 2022-11-22 14:06:36 +05:30
vishnukvmd
34c9ee76b3 Improve logging 2022-11-22 14:06:25 +05:30
vishnukvmd
3fbad241b1 Remove irrelevant link 2022-11-22 13:57:38 +05:30
vishnukvmd
9143634000 Execute remote sync if local items were pushed 2022-11-22 13:56:00 +05:30
vishnukvmd
a573c012e0 Rename variable 2022-11-22 13:37:09 +05:30
vishnukvmd
94debda2c7 Add the ability to add and edit issuers 2022-11-22 13:24:37 +05:30
vishnukvmd
c9f37b4f5a Add ability to edit an existing code 2022-11-22 13:24:37 +05:30
Neeraj Gupta
05652656ae Update README.md 2022-11-16 16:15:13 +05:30
vishnukvmd
8e715b4cf9 v1.0.15 2022-11-15 17:44:18 +05:30
vishnukvmd
e264d3456b Add indicator for codes that haven't synced to remote yet 2022-11-15 17:44:04 +05:30
vishnukvmd
56a73ab7ee Send stack trace on error capture 2022-11-15 17:27:12 +05:30
vishnukvmd
963d18b501 Gracefully parse errors 2022-11-15 17:26:59 +05:30
vishnukvmd
b35d3bda16 Reset cache on logout 2022-11-15 17:21:44 +05:30
vishnukvmd
7ad76adcaa v1.0.14 2022-11-15 14:04:26 +05:30
vishnukvmd
691eaabf50 Switch to FlutterFragmentActivity 2022-11-15 14:04:17 +05:30
vishnukvmd
7ab16df330 Update readme 2022-11-14 22:46:08 +05:30
vishnukvmd
3c16cfd829 Update podfile 2022-11-14 22:46:02 +05:30
vishnukvmd
8c9c9c53d1 v1.0.12 2022-11-14 22:11:38 +05:30
vishnukvmd
aeaaddbe40 Update README 2022-11-14 22:11:14 +05:30
vishnukvmd
25782870c7 Remove wasteful refresh 2022-11-14 21:53:14 +05:30
vishnukvmd
39e28dd63b Sort codes by issuer 2022-11-14 21:51:25 +05:30
vishnukvmd
517ce33fd9 Display the account within the code widget 2022-11-14 21:49:00 +05:30
vishnukvmd
02d2cb5733 Document architecture 2022-11-14 20:46:43 +05:30
vishnukvmd
2b5f349b2f Copy code on long press as well as on tap 2022-11-14 20:30:15 +05:30
vishnukvmd
08ad496975 Update README 2022-11-14 20:02:32 +05:30
vishnukvmd
f52ee5683b v1.0.11 2022-11-14 18:57:45 +05:30
vishnukvmd
332faa9166 Send error messages to sentry, even if the error object is missing 2022-11-14 18:57:37 +05:30
27 changed files with 937 additions and 134 deletions

View File

@@ -1,3 +1,68 @@
# ente Auth
# ente Authenticator
Open source authenticator app for your 2FA secrets, with end-to-end encrypted backups.
ente's Authenticator app helps you generate and store 2 step verification (2FA)
tokens on your mobile devices.
## ✨ Features
### Secure Backups
ente provides end-to-end encrypted cloud backups so that you don't have to worry
about losing your tokens. We use the same protocols [ente
Photos](https://ente.io) uses to encrypt and preserve your data.
### Multi Device Synchronization
ente will automatically sync the 2FA tokens you add to your account, across all
your devices. Every new device you sign into will have access to these tokens.
### Offline Mode
ente generates 2FA tokens offline, so your network connectivity will not get in
the way of your workflow.
### Import and Export Tokens
You can add tokens to ente by one of the following methods:
1. Scanning a QR code
2. Manually entering (copy-pasting) a 2FA secret
3. Bulk importing from a file that contains a list of codes in the following
format:
```
otpauth://totp/ACCOUNT?secret=SUPERSECRET&issuer=SERVICE
```
The codes maybe separated by new lines or commas.
You can also export the codes you have added to ente, to an **unencrypted** text
file, that adheres to the above format.
## 🔩 Architecture
The architecture that powers end-to-end encrypted storage and sync of your
tokens has been documented [here](architecture/index.md).
## 🧑‍💻 Building from source
1. [Install Flutter](https://flutter.dev/docs/get-started/install)
2. Clone this repository with `git clone git@github.com:ente-io/auth.git`
3. Pull in all submodules with `git submodule update --init --recursive`
4. For Android, run `flutter build apk --release --flavor independent`
5. For iOS, run `flutter build ios`
## 🙋‍♂️ Support
If you need help, please reach out to support@ente.io, and a human will get in
touch with you.
On the other hand, if you wish to support us, please
[star](https://github.com/ente-io/auth/stargazers) this project.
## 💜 Community
- Follow us on [Twitter](https://twitter.com/enteio)
- Join us on [Discord](https://ente.io/discord)

View File

@@ -1,6 +1,6 @@
package io.ente.auth
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.android.FlutterFragmentActivity
class MainActivity: FlutterActivity() {
class MainActivity: FlutterFragmentActivity() {
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 119 KiB

11
architecture/e2ee.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 105 KiB

275
architecture/index.md Normal file
View File

@@ -0,0 +1,275 @@
# Architecture
This is an overview of ente's end-to-end encrypted architecture and
specifications of the underlying cryptography.
## Introduction
Your data is end-to-end encrypted with **ente**. Meaning, they are encrypted
with your `keys` before they leave your device.
<img src="e2ee.svg" class="architecture-svg" style="max-width: 600px"
title="End-to-end encryption in ente" />
<br/>
These `keys` are available only to you. Meaning only you can access your data
else where.
What follows is an explanation of how we do what we do.
## Key Encryption
### Fundamentals
#### Master Key
When you sign up for **ente**, your client generates a `masterKey` for you. This
never leaves your device unencrypted.
#### Key Encryption Key
Once you choose a password, a `keyEncryptionKey` is derived from it. This never
leaves your device.
### Flows
#### Primary Device
During registration, your `masterKey` is encrypted with your`keyEncryptionKey`,
and the resultant `encryptedMasterKey` is then sent to our servers for storage.
<img src="key-derivation.svg" class="architecture-svg" title="Key derivation" />
#### <a id="key-encryption-flows-secondary-device"></a> Secondary Device
When you sign in on a secondary device, after you successfully verify your
email, our servers give you back your `encryptedMasterKey` that was sent to us
by your primary device.
You are then prompted to enter your password. Once entered, your
`keyEncryptionKey` is derived, and the client decrypts your `encryptedMasterKey`
with this, to yield your original `masterKey`.
If the decryption fails, the client will know that the derived
`keyEncryptionKey` was wrong, indicating an incorrect password, and this
information will be surfaced to you.
### Privacy
- Since only you know your password, only you can derive your
`keyEncryptionKey`.
- Since only you can derive your `keyEncryptionKey`, only you have access to
your `masterKey`.
> Keep reading to learn about how this `masterKey` is used to encrypt your data.
---
## Token Encryption
### Fundamentals
#### Token Key
Each of your tokens in **ente** are encrypted with a `tokenKey`. These never
leave your device unencrypted.
#### Authenticator Key
Each of your `tokenKey`s are in turn encrypted with an `authKey`. This never
leave your device unencrypted.
### Flows
#### Upload
- Each token and associated metadata is encrypted with randomly generated
`tokenKey`s.
- Each `tokenKey` is encrypted with your `authKey`. In case your account does
not have an `authKey` yet, one is randomly generated and encrypted with your
`masterKey`.
- All of the above mentioned encrypted data is then pushed to the server for
storage.
<img src="token-encryption.svg" class="architecture-svg" title="Token
encryption" />
#### Download
- All of the above mentioned encrypted data is pulled from the server.
- You first decrypt your `authKey` with your `masterKey`.
- You then decrypt each token's `tokenKey` with your `authKey`.
- Finally, you decrypt each token and associated metadata with the respective
`tokenKey`s.
### Privacy
- As explained in the previous section, only you have access to your
`masterKey`.
- Since only you have access to your `masterKey`, only you can decrypt your
`authKey`.
- Since only you have access to your `authKey`, only you can decrypt the
`tokenKey`s.
- Since only you have access to the `tokenKey`s, only you can decrypt the tokens
and their associated metadata.
---
## Key Recovery
### Fundamentals
#### Recovery Key
When you sign up for **ente**, your app generates a `recoveryKey` for you. This
never leaves your device unencrypted.
### Flow
#### Storage
Your `recoveryKey` and `masterKey` are encrypted with each other and stored on
the server.
#### Access
This encrypted `recoveryKey` is downloaded when you sign in on a new device.
This is decrypted with your `masterKey` and surfaced to you whenever you request
for it.
#### Recovery
Post email verification, if you're unable to unlock your account because you
have forgotten your password, the client will prompt you to enter your
`recoveryKey`.
The client then pulls the `masterKey` that was earlier encrypted and pushed to
the server (as discussed in [Key Encryption](#key-encryption), and decrypts it
with the entered `recoveryKey`. If the decryption succeeds, the client will know
that you have entered the correct `recoveryKey`.
<img src="recovery.svg" class="architecture-svg" title="Recovery" />
Now that you have your `masterKey`, the client will prompt you to set a new
password, using which it will derive a new `keyEncryptionKey`. This is then used
to encrypt your `masterKey` and this new `encryptedMasterKey` is uploaded to our
servers, similar to what was earlier discussed in [Key
Encryption](#key-encryption).
### Privacy
- Since only you have access to your `masterKey`, only you can access your
`recoveryKey`.
- Since only you can access your `recoveryKey`, only you can reset your
password.
---
## Authentication
### Fundamentals
#### One Time Token
When you attempt to verify ownership of an email address, our server generates a
`oneTimeToken`, that if presented confirms your access to the said email
address. This token is valid for a short time and can only be used once.
#### Authentication Token
When you successfully authenticate yourself against our server by proving
ownership of your email (and in future any other configured vectors), the server
generates an `authToken`, that can from there on be used to authenticate against
our private APIs.
#### Encrypted Authentication Token
A generated `authToken` is returned to your client after being encrypted with
your `publicKey`. This `encryptedAuthToken` can only be decrypted with your
`privateKey`.
### Flow
- You are asked for an email address, to which a `oneTimeToken` is sent.
- Once you present this information correctly to our server, an `authToken` is
generated and an `encryptedAuthToken` is returned to you, along with your
other encrypted keys.
- You are then prompted to enter your password, using which your `masterKey` is
derived (as discussed [here](#key-encryption-flows-secondary-device)).
- Using this `masterKey`, the rest of your keys, including your `privateKey` is
decrypted (as discussed [here](#private-key)).
- Using your `privateKey`, the client will then decrypt the `encryptedAuthToken`
that was earlier encrypted by our server with your `publicKey`.
- This decrypted `authToken` can then from there on be used to authenticate all
API calls against our servers.
<img src="authentication.svg" class="architecture-svg" title="Authentication" />
### Security
Only by verifying access to your email and knowing your password can you obtain
an`authToken` that can be used to authenticate yourself against our servers.
---
## Implementation Details
We rely on the high level APIs exposed by this wonderful library called
[libsodium](https://libsodium.gitbook.io/doc/).
#### Key Generation
[`crypto_secretbox_keygen`](https://libsodium.gitbook.io/doc/public-key_cryptography/sealed_boxes)
is used to generate all random keys within the application. Your `masterKey`,
`recoveryKey`, `authKey`, `tokenKey` are all 256-bit keys generated using this
API.
#### Key Derivation
[`crypto_pwhash`](https://libsodium.gitbook.io/doc/password_hashing/default_phf)
is used to derive your `keyEncryptionKey` from your password.
`crypto_pwhash_OPSLIMIT_SENSITIVE` and `crypto_pwhash_MEMLIMIT_SENSITIVE` are
used as the limits for computation and memory respectively. If the operation
fails due to insufficient memory, the former is doubled and the latter is halved
progressively, until a key can be derived. If during this process the memory
limit is reduced to a value less than `crypto_pwhash_MEMLIMIT_MIN`, the client
will not let you register from that device.
Internally, this uses [Argon2
v1.3](https://github.com/P-H-C/phc-winner-argon2/raw/master/argon2-specs.pdf),
which is regarded as [one of the best hashing
algorithms](https://en.wikipedia.org/wiki/Argon2) currently available.
#### Symmetric Encryption
[`crypto_secretbox_easy`](https://libsodium.gitbook.io/doc/secret-key_cryptography/secretbox)
is used to encrypt your `masterKey`, `recoveryKey`, `authKey` and `tokenKey`s.
Internally, this uses
[XSalsa20](https://libsodium.gitbook.io/doc/advanced/stream_ciphers/xsalsa20)
stream cipher with [Poly1305
MAC](https://datatracker.ietf.org/doc/html/rfc8439#section-2.5) for
authentication.
[`crypto_secretstream_*`](https://libsodium.gitbook.io/doc/secret-key_cryptography/secretstream)
APIs are used to encrypt your token data. Internally, this uses
[XChaCha20](https://libsodium.gitbook.io/doc/advanced/stream_ciphers/xchacha20)
stream cipher with [Poly1305
MAC](https://datatracker.ietf.org/doc/html/rfc8439#section-2.5) for
authentication.
#### Salt & Nonce Generation
[`randombytes_buf`](https://libsodium.gitbook.io/doc/generating_random_data) is
used to generate a new salt/nonce every time data needs to be hashed/encrypted.
---
## Further Details
Thank you for reading this far! For implementation details, we request you to
checkout [our code](https://github.com/ente-io).
If you'd like to help us improve this document, kindly email
[security@ente.io](mailto:security@ente.io).

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 76 KiB

33
architecture/recovery.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 130 KiB

21
architecture/sharing.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 123 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -50,6 +50,8 @@ PODS:
- flutter_inappwebview/Core (0.0.1):
- Flutter
- OrderedSet (~> 5.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (0.0.1):
- Flutter
- flutter_secure_storage (3.3.1):
@@ -69,6 +71,8 @@ PODS:
- move_to_background (0.0.1):
- Flutter
- MTBBarcodeScanner (5.0.11)
- open_file (0.0.1):
- Flutter
- OrderedSet (5.0.0)
- package_info_plus (0.4.5):
- Flutter
@@ -106,6 +110,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_sodium (from `.symlinks/plugins/flutter_sodium/ios`)
@@ -113,6 +118,7 @@ DEPENDENCIES:
- in_app_purchase (from `.symlinks/plugins/in_app_purchase/ios`)
- local_auth (from `.symlinks/plugins/local_auth/ios`)
- move_to_background (from `.symlinks/plugins/move_to_background/ios`)
- open_file (from `.symlinks/plugins/open_file/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- qr_code_scanner (from `.symlinks/plugins/qr_code_scanner/ios`)
@@ -150,6 +156,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_email_sender/ios"
flutter_inappwebview:
:path: ".symlinks/plugins/flutter_inappwebview/ios"
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
@@ -164,6 +172,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/local_auth/ios"
move_to_background:
:path: ".symlinks/plugins/move_to_background/ios"
open_file:
:path: ".symlinks/plugins/open_file/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_ios:
@@ -191,6 +201,7 @@ SPEC CHECKSUMS:
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
flutter_sodium: c84426b4de738514b5b66cfdeb8a06634e72fe0b
@@ -200,6 +211,7 @@ SPEC CHECKSUMS:
local_auth: 1740f55d7af0a2e2a8684ce225fe79d8931e808c
move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
open_file: 02eb5cb6b21264bd3a696876f5afbfb7ca4f4b7d
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02

View File

@@ -7,7 +7,6 @@ import 'package:ente_auth/core/constants.dart';
import 'package:ente_auth/core/errors.dart';
import 'package:ente_auth/core/event_bus.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:ente_auth/core/logging/super_logging.dart';
import 'package:ente_auth/events/signed_in_event.dart';
import 'package:ente_auth/events/signed_out_event.dart';
import 'package:ente_auth/models/key_attributes.dart';
@@ -20,7 +19,6 @@ import 'package:flutter_sodium/flutter_sodium.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
class Configuration {
Configuration._privateConstructor();
@@ -43,7 +41,6 @@ class Configuration {
static const userIDKey = "user_id";
static const hasMigratedSecureStorageToFirstUnlockKey =
"has_migrated_secure_storage_to_first_unlock";
static const anonymousUserIDKey = "anonymous_user_id";
final kTempFolderDeletionTimeBuffer = const Duration(days: 1).inMicroseconds;
@@ -120,7 +117,6 @@ class Configuration {
}
await _migrateSecurityStorageToFirstUnlock();
}
SuperLogging.setUserID(await _getOrCreateAnonymousUserID());
}
Future<void> logout({bool autoLogout = false}) async {
@@ -130,6 +126,7 @@ class Configuration {
_key = null;
_cachedToken = null;
_secretKey = null;
_authSecretKey = null;
Bus.instance.fire(SignedOutEvent());
}
@@ -494,12 +491,4 @@ class Configuration {
);
}
}
Future<String> _getOrCreateAnonymousUserID() async {
if (!_preferences.containsKey(anonymousUserIDKey)) {
//ignore: prefer_const_constructors
await _preferences.setString(anonymousUserIDKey, Uuid().v4());
}
return _preferences.getString(anonymousUserIDKey)!;
}
}

View File

@@ -17,6 +17,8 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart';
typedef FutureOrVoidCallback = FutureOr<void> Function();
@@ -144,54 +146,51 @@ class SuperLogging {
/// The current super logging configuration
static LogConfig config;
static SharedPreferences _preferences;
static const keyShouldReportErrors = "should_report_errors";
static const keyAnonymousUserID = "anonymous_user_id";
static Future<void> main([LogConfig config]) async {
config ??= LogConfig();
SuperLogging.config = config;
WidgetsFlutterBinding.ensureInitialized();
appVersion ??= await getAppVersion();
final isFDroidClient = await isFDroidBuild();
if (isFDroidClient) {
config.sentryDsn = null;
config.tunnel = null;
}
_preferences = await SharedPreferences.getInstance();
final enable = config.enableInDebugMode || kReleaseMode;
sentryIsEnabled = enable && config.sentryDsn != null && !isFDroidClient;
fileIsEnabled = enable && config.logDirPath != null;
appVersion ??= await getAppVersion();
final loggingEnabled = config.enableInDebugMode || kReleaseMode;
sentryIsEnabled =
loggingEnabled && config.sentryDsn != null && shouldReportErrors();
fileIsEnabled = loggingEnabled && config.logDirPath != null;
if (fileIsEnabled) {
await setupLogDir();
}
if (sentryIsEnabled) {
setupSentry();
}
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen(onLogRecord);
if (isFDroidClient) {
assert(
sentryIsEnabled == false,
"sentry dsn should be disabled for "
"f-droid config ${config.sentryDsn} & ${config.tunnel}",
);
if (sentryIsEnabled) {
setupSentry();
} else {
$.info("Sentry is disabled");
}
if (!enable) {
if (!loggingEnabled) {
$.info("detected debug mode; sentry & file logging disabled.");
}
if (fileIsEnabled) {
$.info("log file for today: $logFile with prefix ${config.prefix}");
}
if (sentryIsEnabled) {
$.info("sentry uploader started");
}
if (config.body == null) return;
if (enable && sentryIsEnabled) {
if (loggingEnabled && sentryIsEnabled) {
await SentryFlutter.init(
(options) {
options.dsn = config.sentryDsn;
@@ -252,8 +251,16 @@ class SuperLogging {
}
// add error to sentry queue
if (sentryIsEnabled && rec.error != null) {
_sendErrorToSentry(rec.error, null);
if (sentryIsEnabled) {
if (rec.error != null) {
_sendErrorToSentry(rec.error, null);
} else if (rec.level == Level.SEVERE || rec.level == Level.SHOUT) {
if (rec.error != null) {
_sendErrorToSentry(rec.error, null);
} else {
_sendErrorToSentry(rec.message, null);
}
}
}
}
@@ -288,6 +295,8 @@ class SuperLogging {
static bool sentryIsEnabled;
static Future<void> setupSentry() async {
$.info("Setting up sentry");
SuperLogging.setUserID(await _getOrCreateAnonymousUserID());
await for (final error in sentryQueueControl.stream.asBroadcastStream()) {
try {
Sentry.captureException(
@@ -307,6 +316,26 @@ class SuperLogging {
sentryQueueControl.add(error);
}
static bool shouldReportErrors() {
if (_preferences.containsKey(keyShouldReportErrors)) {
return _preferences.getBool(keyShouldReportErrors);
} else {
return false;
}
}
static Future<void> setShouldReportErrors(bool value) {
return _preferences.setBool(keyShouldReportErrors, value);
}
static Future<String> _getOrCreateAnonymousUserID() async {
if (!_preferences.containsKey(keyAnonymousUserID)) {
//ignore: prefer_const_constructors
await _preferences.setString(keyAnonymousUserID, Uuid().v4());
}
return _preferences.getString(keyAnonymousUserID);
}
/// The log file currently in use.
static File logFile;

View File

@@ -11,12 +11,13 @@
"importScanQrCode": "Scan a QR Code",
"importEnterSetupKey": "Enter a setup key",
"importAccountPageTitle": "Enter account details",
"accountNameHint": "Account name",
"accountKeyHint" : "Your key",
"codeIssuerHint": "Issuer",
"codeSecretKeyHint" : "Secret Key",
"codeAccountHint": "Account (you@domain.com)",
"accountKeyType": "Type of key",
"timeBasedKeyType": "Time based (TOTP)",
"counterBasedKeyType": "Counter based (HOTP)",
"importAddAction": "Add",
"saveAction": "Save",
"existingUser": "Existing User",
"newUser" : "New to ente"

View File

@@ -8,6 +8,7 @@ import 'package:ente_auth/ente_theme_data.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:ente_auth/services/billing_service.dart';
import 'package:ente_auth/services/notification_service.dart';
import 'package:ente_auth/services/preference_service.dart';
import 'package:ente_auth/services/update_service.dart';
import 'package:ente_auth/services/user_remote_flag_service.dart';
import 'package:ente_auth/services/user_service.dart';
@@ -51,7 +52,6 @@ Future _runWithLogs(Function() function, {String prefix = ""}) async {
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
maxLogFiles: 5,
sentryDsn: sentryDSN,
tunnel: sentryTunnel,
enableInDebugMode: true,
prefix: prefix,
),
@@ -61,6 +61,7 @@ Future _runWithLogs(Function() function, {String prefix = ""}) async {
Future<void> _init(bool bool, {String via}) async {
InAppPurchaseConnection.enablePendingPurchases();
CryptoUtil.init();
await PreferenceService.instance.init();
await CodeStore.instance.init();
await Configuration.instance.init();
await Network.instance.init();

View File

@@ -0,0 +1,7 @@
class EntityResult {
final int generatedID;
final String rawData;
final bool hasSynced;
EntityResult(this.generatedID, this.rawData, this.hasSynced);
}

View File

@@ -4,7 +4,7 @@ class Code {
static const defaultDigits = 6;
static const defaultPeriod = 30;
int? id;
int? generatedID;
final String account;
final String issuer;
final int digits;
@@ -13,6 +13,7 @@ class Code {
final Algorithm algorithm;
final Type type;
final String rawData;
bool? hasSynced;
Code(
this.account,
@@ -23,24 +24,28 @@ class Code {
this.algorithm,
this.type,
this.rawData, {
this.id,
this.generatedID,
});
static Code fromAccountAndSecret(String account, String secret) {
static Code fromAccountAndSecret(
String account,
String issuer,
String secret,
) {
return Code(
account,
"",
issuer,
defaultDigits,
defaultPeriod,
secret,
Algorithm.sha1,
Type.totp,
"otpauth://totp/" +
account +
issuer +
":" +
account +
"?algorithm=SHA1&digits=6&issuer=" +
account +
issuer +
"period=30&secret=" +
secret,
);

View File

@@ -1,11 +1,14 @@
import "package:ente_auth/l10n/l10n.dart";
import 'package:ente_auth/models/code.dart';
// ignore: import_of_legacy_library_into_null_safe
import 'package:ente_auth/utils/dialog_util.dart';
import 'package:ente_auth/utils/totp_util.dart';
import "package:flutter/material.dart";
class SetupEnterSecretKeyPage extends StatefulWidget {
SetupEnterSecretKeyPage({Key? key}) : super(key: key);
final Code? code;
SetupEnterSecretKeyPage({this.code, Key? key}) : super(key: key);
@override
State<SetupEnterSecretKeyPage> createState() =>
@@ -13,8 +16,27 @@ class SetupEnterSecretKeyPage extends StatefulWidget {
}
class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
final _accountController = TextEditingController();
final _secretController = TextEditingController(text: "");
late TextEditingController _issuerController;
late TextEditingController _accountController;
late TextEditingController _secretController;
@override
void initState() {
_issuerController = TextEditingController(
text: widget.code != null
? Uri.decodeFull(widget.code!.issuer).trim()
: null,
);
_accountController = TextEditingController(
text: widget.code != null
? Uri.decodeFull(widget.code!.account).trim()
: null,
);
_secretController = TextEditingController(
text: widget.code != null ? widget.code!.secret : null,
);
super.initState();
}
@override
Widget build(BuildContext context) {
@@ -38,9 +60,9 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
return null;
},
decoration: InputDecoration(
hintText: l10n.accountNameHint,
hintText: l10n.codeIssuerHint,
),
controller: _accountController,
controller: _issuerController,
autofocus: true,
),
const SizedBox(
@@ -55,10 +77,26 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
return null;
},
decoration: InputDecoration(
hintText: l10n.accountKeyHint,
hintText: l10n.codeSecretKeyHint,
),
controller: _secretController,
),
const SizedBox(
height: 20,
),
TextFormField(
// The validator receives the text that the user has entered.
validator: (value) {
if (value == null || value.isEmpty) {
return "Please enter some text";
}
return null;
},
decoration: InputDecoration(
hintText: l10n.codeAccountHint,
),
controller: _accountController,
),
const SizedBox(
height: 40,
),
@@ -74,10 +112,14 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
try {
final code = Code.fromAccountAndSecret(
_accountController.text.trim(),
_issuerController.text.trim(),
_secretController.text.trim(),
);
// Verify the validity of the code
getTotp(code);
if (widget.code != null) {
code.generatedID = widget.code!.generatedID;
}
Navigator.of(context).pop(code);
} catch (e) {
_showIncorrectDetailsDialog(context);
@@ -88,7 +130,7 @@ class _SetupEnterSecretKeyPageState extends State<SetupEnterSecretKeyPage> {
horizontal: 16.0,
vertical: 4,
),
child: Text(l10n.importAddAction),
child: Text(l10n.saveAction),
),
),
)

View File

@@ -11,6 +11,7 @@ import 'package:ente_auth/events/signed_in_event.dart';
import 'package:ente_auth/gateway/authenticator.dart';
import 'package:ente_auth/models/authenticator/auth_entity.dart';
import 'package:ente_auth/models/authenticator/auth_key.dart';
import 'package:ente_auth/models/authenticator/entity_result.dart';
import 'package:ente_auth/models/authenticator/local_auth_entity.dart';
import 'package:ente_auth/store/authenticator_db.dart';
import 'package:ente_auth/utils/crypto_util.dart';
@@ -44,22 +45,33 @@ class AuthenticatorService {
});
}
Future<Map<int, String>> getAllIDtoStringMap() async {
Future<List<EntityResult>> getEntities() async {
final List<LocalAuthEntity> result = await _db.getAll();
final Map<int, String> entries = <int, String>{};
final List<EntityResult> entities = [];
if (result.isEmpty) {
return entries;
return entities;
}
final key = await getOrCreateAuthDataKey();
for (LocalAuthEntity e in result) {
final decryptedValue = await CryptoUtil.decryptChaCha(
Sodium.base642bin(e.encryptedData),
key,
Sodium.base642bin(e.header),
);
entries[e.generatedID] = utf8.decode(decryptedValue);
try {
final decryptedValue = await CryptoUtil.decryptChaCha(
Sodium.base642bin(e.encryptedData),
key,
Sodium.base642bin(e.header),
);
final hasSynced = !(e.id == null || e.shouldSync);
entities.add(
EntityResult(
e.generatedID,
utf8.decode(decryptedValue),
hasSynced,
),
);
} catch (e, s) {
_logger.severe(e, s);
}
}
return entries;
return entities;
}
Future<int> addEntry(String plainText, bool shouldSync) async {
@@ -77,7 +89,11 @@ class AuthenticatorService {
return insertedID;
}
Future<void> updateEntry(int generatedID, String plainText) async {
Future<void> updateEntry(
int generatedID,
String plainText,
bool shouldSync,
) async {
var key = await getOrCreateAuthDataKey();
final encryptedKeyData = await CryptoUtil.encryptChaCha(
utf8.encode(plainText) as Uint8List,
@@ -91,7 +107,9 @@ class AuthenticatorService {
affectedRows == 1,
"updateEntry should have updated exactly one row",
);
unawaited(sync());
if (shouldSync) {
unawaited(sync());
}
}
Future<void> deleteEntry(int genID) async {
@@ -109,15 +127,9 @@ class AuthenticatorService {
Future<void> sync() async {
try {
_logger.info("Sync");
_logger.info("State of DB before sync");
await _printDBState();
await _remoteToLocalSync();
_logger.info("remote fetch completed");
_logger.info("State of DB after remoteToLocal sync");
await _printDBState();
await _localToRemoteSync();
_logger.info("State of DB after localToRemote sync");
await _printDBState();
_logger.info("local push completed");
Bus.instance.fire(CodesUpdatedEvent());
} catch (e) {
@@ -128,24 +140,27 @@ class AuthenticatorService {
Future<void> _remoteToLocalSync() async {
_logger.info('Initiating remote to local sync');
final int lastSyncTime = _prefs.getInt(_lastEntitySyncTime) ?? 0;
_logger.info("Current synctime is " + lastSyncTime.toString());
const int fetchLimit = 500;
final List<AuthEntity> result =
await _gateway.getDiff(lastSyncTime, limit: fetchLimit);
_logger.info(result.length.toString() + " entries fetched from remote");
if (result.isEmpty) {
return;
} else {
_logger.info(result.length.toString() + " entries fetched from remote");
}
final maxSyncTime = result.map((e) => e.updatedAt).reduce(max);
List<String> deletedIDs =
result.where((element) => element.isDeleted).map((e) => e.id).toList();
_logger.info(deletedIDs.length.toString() + " entries deleted");
result.removeWhere((element) => element.isDeleted);
await _db.insertOrReplace(result);
if (deletedIDs.isNotEmpty) {
await _db.deleteByIDs(ids: deletedIDs);
}
_prefs.setInt(_lastEntitySyncTime, maxSyncTime);
_logger.info("Setting synctime to " + maxSyncTime.toString());
if (result.length == fetchLimit) {
_logger.info("Diff limit reached, pulling again");
await _remoteToLocalSync();
}
}
@@ -180,6 +195,10 @@ class AuthenticatorService {
await _db.updateLocalEntity(entity.copyWith(shouldSync: false));
}
}
if (pendingUpdate.isNotEmpty) {
_logger.info("Initiating remote sync since local entries were pushed");
await _remoteToLocalSync();
}
}
Future<Uint8List> getOrCreateAuthDataKey() async {
@@ -210,13 +229,4 @@ class AuthenticatorService {
rethrow;
}
}
Future<void> _printDBState() async {
_logger.info("_____");
final entities = await _db.getAll();
for (final entity in entities) {
_logger.info(entity.id);
}
_logger.info("_____");
}
}

View File

@@ -0,0 +1,27 @@
import 'package:shared_preferences/shared_preferences.dart';
class PreferenceService {
PreferenceService._privateConstructor();
static final PreferenceService instance =
PreferenceService._privateConstructor();
late final SharedPreferences _prefs;
static const kHasShownCoachMarkKey = "has_shown_coach_mark";
Future<void> init() async {
_prefs = await SharedPreferences.getInstance();
}
bool hasShownCoachMark() {
if (_prefs.containsKey(kHasShownCoachMarkKey)) {
return _prefs.getBool(kHasShownCoachMarkKey)!;
} else {
return false;
}
}
Future<void> setHasShownCoachMark(bool value) {
return _prefs.setBool(kHasShownCoachMarkKey, value);
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:ente_auth/core/event_bus.dart';
import 'package:ente_auth/events/codes_updated_event.dart';
import 'package:ente_auth/models/authenticator/entity_result.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/services/authenticator_service.dart';
import 'package:logging/logging.dart';
@@ -19,15 +20,19 @@ class CodeStore {
}
Future<List<Code>> getAllCodes() async {
final Map<int, String> rawCodesMap =
await _authenticatorService.getAllIDtoStringMap();
final List<EntityResult> entities =
await _authenticatorService.getEntities();
final List<Code> codes = [];
for (final entry in rawCodesMap.entries) {
final decodeJson = jsonDecode(entry.value);
for (final entity in entities) {
final decodeJson = jsonDecode(entity.rawData);
final code = Code.fromRawData(decodeJson);
code.id = entry.key;
code.generatedID = entity.generatedID;
code.hasSynced = entity.hasSynced;
codes.add(code);
}
codes.sort((c1, c2) {
return c1.issuer.toLowerCase().compareTo(c2.issuer.toLowerCase());
});
return codes;
}
@@ -36,21 +41,33 @@ class CodeStore {
bool shouldSync = true,
}) async {
final codes = await getAllCodes();
bool isExistingCode = false;
for (final existingCode in codes) {
if (existingCode == code) {
_logger.info("Found duplicate code, skipping add");
return;
} else if (existingCode.generatedID == code.generatedID) {
isExistingCode = true;
break;
}
}
code.id = await _authenticatorService.addEntry(
jsonEncode(code.rawData),
shouldSync,
);
if (isExistingCode) {
await _authenticatorService.updateEntry(
code.generatedID!,
jsonEncode(code.rawData),
shouldSync,
);
} else {
code.generatedID = await _authenticatorService.addEntry(
jsonEncode(code.rawData),
shouldSync,
);
}
Bus.instance.fire(CodesUpdatedEvent());
}
Future<void> removeCode(Code code) async {
await _authenticatorService.deleteEntry(code.id!);
await _authenticatorService.deleteEntry(code.generatedID!);
Bus.instance.fire(CodesUpdatedEvent());
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:clipboard/clipboard.dart';
import 'package:ente_auth/ente_theme_data.dart';
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/utils/toast_util.dart';
import 'package:ente_auth/utils/totp_util.dart';
@@ -55,6 +56,20 @@ class _CodeWidgetState extends State<CodeWidget> {
endActionPane: ActionPane(
motion: const ScrollMotion(),
children: [
SlidableAction(
onPressed: _onEditPressed,
backgroundColor: Colors.grey.withOpacity(0.1),
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
foregroundColor:
Theme.of(context).colorScheme.inverseBackgroundColor,
icon: Icons.edit_outlined,
label: 'Edit',
padding: const EdgeInsets.only(left: 4, right: 0),
spacing: 8,
),
const SizedBox(
width: 4,
),
SlidableAction(
onPressed: _onDeletePressed,
backgroundColor: Colors.grey.withOpacity(0.1),
@@ -63,6 +78,7 @@ class _CodeWidgetState extends State<CodeWidget> {
icon: Icons.delete,
label: 'Delete',
padding: const EdgeInsets.only(left: 0, right: 0),
spacing: 8,
),
],
),
@@ -79,9 +95,10 @@ class _CodeWidgetState extends State<CodeWidget> {
borderRadius: BorderRadius.circular(10),
),
onTap: () {
FlutterClipboard.copy(_getTotp()).then(
(value) => showToast(context, "Copied to clipboard"),
);
_copyToClipboard();
},
onLongPress: () {
_copyToClipboard();
},
child: SizedBox(
child: Column(
@@ -102,27 +119,50 @@ class _CodeWidgetState extends State<CodeWidget> {
),
Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Text(
Uri.decodeFull(widget.code.issuer),
style: Theme.of(context).textTheme.headline6,
),
),
Container(
padding: const EdgeInsets.only(right: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"next",
style: Theme.of(context).textTheme.caption,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Uri.decodeFull(widget.code.issuer).trim(),
style:
Theme.of(context).textTheme.headline6,
),
const SizedBox(height: 2),
Text(
Uri.decodeFull(
widget.code.account,
).trim(),
style: Theme.of(context)
.textTheme
.caption
?.copyWith(
fontSize: 12,
color: Colors.grey,
),
),
],
),
widget.code.hasSynced != null &&
widget.code.hasSynced!
? Container()
: const Icon(
Icons.sync_disabled,
size: 20,
color: Colors.amber,
),
],
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Text(
@@ -130,12 +170,21 @@ class _CodeWidgetState extends State<CodeWidget> {
style: const TextStyle(fontSize: 24),
),
),
Text(
_getNextTotp(),
style: const TextStyle(
fontSize: 24,
color: Colors.grey,
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
"next",
style: Theme.of(context).textTheme.caption,
),
Text(
_getNextTotp(),
style: const TextStyle(
fontSize: 18,
color: Colors.grey,
),
),
],
),
],
),
@@ -155,6 +204,25 @@ class _CodeWidgetState extends State<CodeWidget> {
);
}
void _copyToClipboard() {
FlutterClipboard.copy(_getTotp()).then(
(value) => showToast(context, "Copied to clipboard"),
);
}
Future<void> _onEditPressed(_) async {
final Code? code = await Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return SetupEnterSecretKeyPage(code: widget.code);
},
),
);
if (code != null) {
CodeStore.instance.addCode(code);
}
}
void _onDeletePressed(_) {
final AlertDialog alert = AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),

View File

@@ -2,6 +2,7 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:ente_auth/core/event_bus.dart';
import 'package:ente_auth/ente_theme_data.dart';
@@ -9,6 +10,7 @@ import 'package:ente_auth/events/codes_updated_event.dart';
import "package:ente_auth/l10n/l10n.dart";
import 'package:ente_auth/models/code.dart';
import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
import 'package:ente_auth/services/preference_service.dart';
import 'package:ente_auth/services/user_service.dart';
import 'package:ente_auth/store/code_store.dart';
import 'package:ente_auth/ui/code_widget.dart';
@@ -41,7 +43,6 @@ class _HomePageState extends State<HomePage> {
_loadCodes();
_streamSubscription = Bus.instance.on<CodesUpdatedEvent>().listen((event) {
_loadCodes();
setState(() {});
});
}
@@ -122,7 +123,11 @@ class _HomePageState extends State<HomePage> {
appBar: AppBar(
title: const Text('ente Authenticator'),
),
floatingActionButton: !_hasLoaded || _codes.isEmpty ? null : _getFab(),
floatingActionButton: !_hasLoaded ||
_codes.isEmpty ||
!PreferenceService.instance.hasShownCoachMark()
? null
: _getFab(),
),
);
}
@@ -132,12 +137,22 @@ class _HomePageState extends State<HomePage> {
if (_codes.isEmpty) {
return _getEmptyState();
} else {
return ListView.builder(
final list = ListView.builder(
itemBuilder: ((context, index) {
return CodeWidget(_codes[index]);
}),
itemCount: _codes.length,
);
if (!PreferenceService.instance.hasShownCoachMark()) {
return Stack(
children: [
list,
_getCoachMarkWidget(),
],
);
} else {
return list;
}
}
} else {
return const EnteLoadingWidget();
@@ -223,6 +238,64 @@ class _HomePageState extends State<HomePage> {
),
);
}
Widget _getCoachMarkWidget() {
return GestureDetector(
onTap: () async {
await PreferenceService.instance.setHasShownCoachMark(true);
setState(() {});
},
child: Row(
children: [
Expanded(
child: Container(
width: double.infinity,
color: Theme.of(context).colorScheme.background.withOpacity(0.1),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.swipe_left,
size: 42,
),
const SizedBox(
height: 24,
),
Text(
"Swipe left to edit or remove codes",
style: Theme.of(context).textTheme.headline6,
),
const SizedBox(
height: 36,
),
SizedBox(
width: 160,
child: OutlinedButton(
onPressed: () async {
await PreferenceService.instance
.setHasShownCoachMark(true);
setState(() {});
},
child: const Text("OK"),
),
)
],
),
],
),
),
),
),
],
),
);
}
}
class SpeedDialLabelWidget extends StatelessWidget {

View File

@@ -28,11 +28,6 @@ class AboutSectionWidget extends StatelessWidget {
Widget _getSectionOptions(BuildContext context) {
return Column(
children: [
sectionOptionSpacing,
const AboutMenuItemWidget(
title: "FAQ",
url: "https://ente.io/faq",
),
sectionOptionSpacing,
const AboutMenuItemWidget(
title: "Terms",

View File

@@ -1,19 +1,24 @@
// @dart=2.9
import 'dart:io';
import 'package:ente_auth/core/constants.dart';
import 'package:ente_auth/core/logging/super_logging.dart';
import 'package:ente_auth/theme/ente_theme.dart';
import 'package:ente_auth/ui/components/captioned_text_widget.dart';
import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart';
import 'package:ente_auth/ui/components/menu_item_widget.dart';
import 'package:ente_auth/ui/components/toggle_switch_widget.dart';
import 'package:ente_auth/ui/settings/common_settings.dart';
import 'package:ente_auth/utils/email_util.dart';
import 'package:flutter/material.dart';
class SupportSectionWidget extends StatelessWidget {
class SupportSectionWidget extends StatefulWidget {
const SupportSectionWidget({Key key}) : super(key: key);
@override
State<SupportSectionWidget> createState() => _SupportSectionWidgetState();
}
class _SupportSectionWidgetState extends State<SupportSectionWidget> {
@override
Widget build(BuildContext context) {
return ExpandableMenuItemWidget(
@@ -24,8 +29,6 @@ class SupportSectionWidget extends StatelessWidget {
}
Widget _getSectionOptions(BuildContext context) {
final String bugsEmail =
Platform.isAndroid ? "android-bugs@ente.io" : "ios-bugs@ente.io";
return Column(
children: [
sectionOptionSpacing,
@@ -49,14 +52,27 @@ class SupportSectionWidget extends StatelessWidget {
trailingIcon: Icons.chevron_right_outlined,
trailingIconIsMuted: true,
onTap: () async {
await sendLogs(context, "Report bug", bugsEmail);
await sendLogs(context, "Report bug", "auth@ente.io");
},
onDoubleTap: () async {
final zipFilePath = await getZippedLogsFile(context);
await shareLogs(context, bugsEmail, zipFilePath);
await shareLogs(context, "auth@ente.io", zipFilePath);
},
),
sectionOptionSpacing,
MenuItemWidget(
captionedTextWidget: const CaptionedTextWidget(
title: "Crash analytics",
),
trailingSwitch: ToggleSwitchWidget(
value: SuperLogging.shouldReportErrors(),
onChanged: (value) async {
await SuperLogging.setShouldReportErrors(value);
setState(() {});
},
),
),
sectionOptionSpacing,
],
);
}

View File

@@ -89,7 +89,9 @@ Future<void> sendLogs(
content.addAll(
[
const Text(
"This will send across logs to help us debug your issue. Please note that file names will be included to help track issues with specific files.",
"This will send across logs to help us debug your issue. "
"While we take precautions to ensure that sensitive information is not "
"logged, we encourage you to view these logs before sharing them.",
style: TextStyle(
height: 1.5,
fontSize: 16,

View File

@@ -1,6 +1,6 @@
name: ente_auth
description: ente two-factor authenticator
version: 1.0.10+10
version: 1.0.17+17
publish_to: none
environment: