mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-01 09:14:06 +00:00
commit
fb7acc9f71
117 changed files with 22745 additions and 2470 deletions
|
|
@ -1,5 +1,11 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
- New: Encrypted Cloud Backup of Memories
|
||||||
|
- New: Passwordless Backup
|
||||||
|
- Fix: Performance issues
|
||||||
|
|
||||||
## 0.3.7
|
## 0.3.7
|
||||||
|
|
||||||
- Fix: Multiple UI issues
|
- Fix: Multiple UI issues
|
||||||
|
|
|
||||||
|
|
@ -36,15 +36,15 @@ If you decide to give twonly a try, please keep in mind that it is still in its
|
||||||
- Open Source and can be downloaded directly from GitHub
|
- Open Source and can be downloaded directly from GitHub
|
||||||
- No email or phone number required to register
|
- No email or phone number required to register
|
||||||
- The backend is hosted exclusively in Europe
|
- The backend is hosted exclusively in Europe
|
||||||
|
- [Passwordless Backup](https://twonly.eu/en/blog/2026-passwordless-backup.html) without a phone number
|
||||||
|
- [User Discovery](https://twonly.eu/en/blog/2026-mutual-friends.html) without a phone number
|
||||||
|
- E2EE cloud backup of memories
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
### Currently
|
### Currently
|
||||||
|
|
||||||
- Focus on user-friendliness so that people enjoy using the app
|
|
||||||
- Passwordless recovery without a phone number
|
|
||||||
- Implementation of features so that Snapchat can actually be replaced
|
- Implementation of features so that Snapchat can actually be replaced
|
||||||
- E2EE cloud backup of memories
|
|
||||||
- Importing memories from Snapchat
|
- Importing memories from Snapchat
|
||||||
|
|
||||||
### Next on the bucket list
|
### Next on the bucket list
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 72d9bd6320bca1f1d29c6e61c3821fed326c0abe
|
Subproject commit 32882a31904a0c1ffd38974bf5158ceee0f3ecb7
|
||||||
|
|
@ -20,12 +20,12 @@ import SwiftProtobuf
|
||||||
// incompatible with the version of SwiftProtobuf to which you are linking.
|
// incompatible with the version of SwiftProtobuf to which you are linking.
|
||||||
// Please ensure that you are building against the same version of the API
|
// Please ensure that you are building against the same version of the API
|
||||||
// that was used to generate this file.
|
// that was used to generate this file.
|
||||||
fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
|
fileprivate nonisolated struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck {
|
||||||
struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
|
struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {}
|
||||||
typealias Version = _2
|
typealias Version = _2
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PushKind: SwiftProtobuf.Enum, Swift.CaseIterable {
|
nonisolated enum PushKind: SwiftProtobuf.Enum, Swift.CaseIterable {
|
||||||
typealias RawValue = Int
|
typealias RawValue = Int
|
||||||
case reaction // = 0
|
case reaction // = 0
|
||||||
case response // = 1
|
case response // = 1
|
||||||
|
|
@ -119,7 +119,7 @@ enum PushKind: SwiftProtobuf.Enum, Swift.CaseIterable {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct EncryptedPushNotification: Sendable {
|
nonisolated struct EncryptedPushNotification: Sendable {
|
||||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||||
// methods supported on all messages.
|
// methods supported on all messages.
|
||||||
|
|
@ -137,7 +137,7 @@ struct EncryptedPushNotification: Sendable {
|
||||||
init() {}
|
init() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PushNotification: Sendable {
|
nonisolated struct PushNotification: Sendable {
|
||||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||||
// methods supported on all messages.
|
// methods supported on all messages.
|
||||||
|
|
@ -170,7 +170,7 @@ struct PushNotification: Sendable {
|
||||||
fileprivate var _additionalContent: String? = nil
|
fileprivate var _additionalContent: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PushUsers: Sendable {
|
nonisolated struct PushUsers: Sendable {
|
||||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||||
// methods supported on all messages.
|
// methods supported on all messages.
|
||||||
|
|
@ -182,7 +182,7 @@ struct PushUsers: Sendable {
|
||||||
init() {}
|
init() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PushUser: Sendable {
|
nonisolated struct PushUser: Sendable {
|
||||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||||
// methods supported on all messages.
|
// methods supported on all messages.
|
||||||
|
|
@ -211,7 +211,7 @@ struct PushUser: Sendable {
|
||||||
fileprivate var _lastMessageID: String? = nil
|
fileprivate var _lastMessageID: String? = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
struct PushKey: Sendable {
|
nonisolated struct PushKey: Sendable {
|
||||||
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
// SwiftProtobuf.Message conformance is added in an extension below. See the
|
||||||
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
// `Message` and `Message+*Additions` files in the SwiftProtobuf library for
|
||||||
// methods supported on all messages.
|
// methods supported on all messages.
|
||||||
|
|
@ -229,11 +229,11 @@ struct PushKey: Sendable {
|
||||||
|
|
||||||
// MARK: - Code below here is support for the SwiftProtobuf runtime.
|
// MARK: - Code below here is support for the SwiftProtobuf runtime.
|
||||||
|
|
||||||
extension PushKind: SwiftProtobuf._ProtoNameProviding {
|
nonisolated extension PushKind: SwiftProtobuf._ProtoNameProviding {
|
||||||
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0REACTION\0\u{1}RESPONSE\0\u{1}TEXT\0\u{1}VIDEO\0\u{1}TWONLY\0\u{1}IMAGE\0\u{1}CONTACT_REQUEST\0\u{1}ACCEPT_REQUEST\0\u{1}STORED_MEDIA_FILE\0\u{1}TEST_NOTIFICATION\0\u{1}REOPENED_MEDIA\0\u{1}REACTION_TO_VIDEO\0\u{1}REACTION_TO_TEXT\0\u{1}REACTION_TO_IMAGE\0\u{1}REACTION_TO_AUDIO\0\u{1}ADDED_TO_GROUP\0\u{1}AUDIO\0")
|
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0REACTION\0\u{1}RESPONSE\0\u{1}TEXT\0\u{1}VIDEO\0\u{1}TWONLY\0\u{1}IMAGE\0\u{1}CONTACT_REQUEST\0\u{1}ACCEPT_REQUEST\0\u{1}STORED_MEDIA_FILE\0\u{1}TEST_NOTIFICATION\0\u{1}REOPENED_MEDIA\0\u{1}REACTION_TO_VIDEO\0\u{1}REACTION_TO_TEXT\0\u{1}REACTION_TO_IMAGE\0\u{1}REACTION_TO_AUDIO\0\u{1}ADDED_TO_GROUP\0\u{1}AUDIO\0")
|
||||||
}
|
}
|
||||||
|
|
||||||
extension EncryptedPushNotification: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
nonisolated extension EncryptedPushNotification: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||||
static let protoMessageName: String = "EncryptedPushNotification"
|
static let protoMessageName: String = "EncryptedPushNotification"
|
||||||
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}key_id\0\u{1}nonce\0\u{1}ciphertext\0\u{1}mac\0")
|
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}key_id\0\u{1}nonce\0\u{1}ciphertext\0\u{1}mac\0")
|
||||||
|
|
||||||
|
|
@ -278,7 +278,7 @@ extension EncryptedPushNotification: SwiftProtobuf.Message, SwiftProtobuf._Messa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PushNotification: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
nonisolated extension PushNotification: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||||
static let protoMessageName: String = "PushNotification"
|
static let protoMessageName: String = "PushNotification"
|
||||||
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}kind\0\u{3}message_id\0\u{3}additional_content\0")
|
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}kind\0\u{3}message_id\0\u{3}additional_content\0")
|
||||||
|
|
||||||
|
|
@ -322,7 +322,7 @@ extension PushNotification: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PushUsers: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
nonisolated extension PushUsers: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||||
static let protoMessageName: String = "PushUsers"
|
static let protoMessageName: String = "PushUsers"
|
||||||
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}users\0")
|
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}users\0")
|
||||||
|
|
||||||
|
|
@ -352,7 +352,7 @@ extension PushUsers: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PushUser: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
nonisolated extension PushUser: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||||
static let protoMessageName: String = "PushUser"
|
static let protoMessageName: String = "PushUser"
|
||||||
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}user_id\0\u{3}display_name\0\u{1}blocked\0\u{3}last_message_id\0\u{3}push_keys\0")
|
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}user_id\0\u{3}display_name\0\u{1}blocked\0\u{3}last_message_id\0\u{3}push_keys\0")
|
||||||
|
|
||||||
|
|
@ -406,7 +406,7 @@ extension PushUser: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension PushKey: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
nonisolated extension PushKey: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding {
|
||||||
static let protoMessageName: String = "PushKey"
|
static let protoMessageName: String = "PushKey"
|
||||||
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}key\0\u{3}created_at_unix_timestamp\0")
|
static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}key\0\u{3}created_at_unix_timestamp\0")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,4 +215,4 @@ SPEC CHECKSUMS:
|
||||||
|
|
||||||
PODFILE CHECKSUM: 245e6d5f26c858edb6b99a7d972cc93ead4d55cf
|
PODFILE CHECKSUM: 245e6d5f26c858edb6b99a7d972cc93ead4d55cf
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.17.0
|
||||||
|
|
|
||||||
42
lib/app.dart
42
lib/app.dart
|
|
@ -1,7 +1,10 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:app_links/app_links.dart';
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
import 'package:flutter_sharing_intent/model/sharing_file.dart' show SharedFile;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
|
@ -11,6 +14,7 @@ import 'package:twonly/src/localization/generated/app_localizations.dart';
|
||||||
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
import 'package:twonly/src/model/json/onboarding_state.model.dart';
|
||||||
import 'package:twonly/src/providers/routing.provider.dart';
|
import 'package:twonly/src/providers/routing.provider.dart';
|
||||||
import 'package:twonly/src/providers/settings.provider.dart';
|
import 'package:twonly/src/providers/settings.provider.dart';
|
||||||
|
import 'package:twonly/src/services/intent/links.intent.dart';
|
||||||
import 'package:twonly/src/utils/keyvalue.dart';
|
import 'package:twonly/src/utils/keyvalue.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/pow.dart';
|
import 'package:twonly/src/utils/pow.dart';
|
||||||
|
|
@ -143,6 +147,10 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
||||||
bool _wasLogged = true;
|
bool _wasLogged = true;
|
||||||
late int _initialPage;
|
late int _initialPage;
|
||||||
|
|
||||||
|
StreamSubscription<Uri>? _deepLinkSub;
|
||||||
|
StreamSubscription<List<SharedFile>>? _intentStreamSub;
|
||||||
|
StreamSubscription<String>? _emailTokenSub;
|
||||||
|
|
||||||
(Future<int>?, bool) _proofOfWork = (null, false);
|
(Future<int>?, bool) _proofOfWork = (null, false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -151,6 +159,28 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
||||||
_initialPage = widget.initialPage;
|
_initialPage = widget.initialPage;
|
||||||
Log.info('AppWidgetState: initState started');
|
Log.info('AppWidgetState: initState started');
|
||||||
initAsync();
|
initAsync();
|
||||||
|
|
||||||
|
void handleShareLink(Uri uri) {
|
||||||
|
routerProvider.go(Routes.home);
|
||||||
|
HomeViewState.streamHomeViewPageIndex.add(1);
|
||||||
|
HomeViewState.streamSharedLink.add(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to all events (initial link and further)
|
||||||
|
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
Log.info('Got link via app links: ${uri.scheme}');
|
||||||
|
if (!await handleIntentUrl(context, uri)) {
|
||||||
|
if (uri.scheme.startsWith('http')) {
|
||||||
|
handleShareLink(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_intentStreamSub = initIntentStreams(
|
||||||
|
context,
|
||||||
|
handleShareLink,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initAsync() async {
|
Future<void> initAsync() async {
|
||||||
|
|
@ -197,10 +227,6 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// await PasswordlessRecoveryService.handleRecoveryLink(
|
|
||||||
// 'https://me.twonly.eu/r/#7fdb8f08-0927-4e44-8761-038993414e48/1p7SKEzpxE3wSW9FQw60EUI4OpSW2U4EskdXLw8xg48',
|
|
||||||
// );
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoaded = true;
|
_isLoaded = true;
|
||||||
});
|
});
|
||||||
|
|
@ -264,4 +290,12 @@ class _AppMainWidgetState extends State<AppMainWidget> {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_deepLinkSub?.cancel();
|
||||||
|
_intentStreamSub?.cancel();
|
||||||
|
_emailTokenSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,24 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||||
class RustKeyManager {
|
class RustKeyManager {
|
||||||
const RustKeyManager();
|
const RustKeyManager();
|
||||||
|
|
||||||
|
static Future<Uint8List> decryptCloudMediaKey({
|
||||||
|
required List<int> encryptedMediaKey,
|
||||||
|
required String addition,
|
||||||
|
}) => RustLib.instance.api
|
||||||
|
.crateBridgeWrapperKeyManagerRustKeyManagerDecryptCloudMediaKey(
|
||||||
|
encryptedMediaKey: encryptedMediaKey,
|
||||||
|
addition: addition,
|
||||||
|
);
|
||||||
|
|
||||||
|
static Future<Uint8List> encryptCloudMediaKey({
|
||||||
|
required List<int> mediaKey,
|
||||||
|
required String addition,
|
||||||
|
}) => RustLib.instance.api
|
||||||
|
.crateBridgeWrapperKeyManagerRustKeyManagerEncryptCloudMediaKey(
|
||||||
|
mediaKey: mediaKey,
|
||||||
|
addition: addition,
|
||||||
|
);
|
||||||
|
|
||||||
static Future<Uint8List> getLoginToken() => RustLib.instance.api
|
static Future<Uint8List> getLoginToken() => RustLib.instance.api
|
||||||
.crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken();
|
.crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,7 @@ class RustLib extends BaseEntrypoint<RustLibApi, RustLibApiImpl, RustLibWire> {
|
||||||
String get codegenVersion => '2.12.0';
|
String get codegenVersion => '2.12.0';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get rustContentHash => 340781866;
|
int get rustContentHash => 1788847092;
|
||||||
|
|
||||||
static const kDefaultExternalLibraryLoaderConfig =
|
static const kDefaultExternalLibraryLoaderConfig =
|
||||||
ExternalLibraryLoaderConfig(
|
ExternalLibraryLoaderConfig(
|
||||||
|
|
@ -205,6 +205,18 @@ abstract class RustLibApi extends BaseApi {
|
||||||
required String password,
|
required String password,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Future<Uint8List>
|
||||||
|
crateBridgeWrapperKeyManagerRustKeyManagerDecryptCloudMediaKey({
|
||||||
|
required List<int> encryptedMediaKey,
|
||||||
|
required String addition,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<Uint8List>
|
||||||
|
crateBridgeWrapperKeyManagerRustKeyManagerEncryptCloudMediaKey({
|
||||||
|
required List<int> mediaKey,
|
||||||
|
required String addition,
|
||||||
|
});
|
||||||
|
|
||||||
Future<Uint8List> crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken();
|
Future<Uint8List> crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken();
|
||||||
|
|
||||||
Future<(Uint8List, PlatformInt64)>
|
Future<(Uint8List, PlatformInt64)>
|
||||||
|
|
@ -1141,6 +1153,82 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
argNames: ["userId", "password"],
|
argNames: ["userId", "password"],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List>
|
||||||
|
crateBridgeWrapperKeyManagerRustKeyManagerDecryptCloudMediaKey({
|
||||||
|
required List<int> encryptedMediaKey,
|
||||||
|
required String addition,
|
||||||
|
}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_list_prim_u_8_loose(encryptedMediaKey, serializer);
|
||||||
|
sse_encode_String(addition, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 18,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_list_prim_u_8_strict,
|
||||||
|
decodeErrorData: sse_decode_AnyhowException,
|
||||||
|
),
|
||||||
|
constMeta:
|
||||||
|
kCrateBridgeWrapperKeyManagerRustKeyManagerDecryptCloudMediaKeyConstMeta,
|
||||||
|
argValues: [encryptedMediaKey, addition],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta
|
||||||
|
get kCrateBridgeWrapperKeyManagerRustKeyManagerDecryptCloudMediaKeyConstMeta =>
|
||||||
|
const TaskConstMeta(
|
||||||
|
debugName: "rust_key_manager_decrypt_cloud_media_key",
|
||||||
|
argNames: ["encryptedMediaKey", "addition"],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List>
|
||||||
|
crateBridgeWrapperKeyManagerRustKeyManagerEncryptCloudMediaKey({
|
||||||
|
required List<int> mediaKey,
|
||||||
|
required String addition,
|
||||||
|
}) {
|
||||||
|
return handler.executeNormal(
|
||||||
|
NormalTask(
|
||||||
|
callFfi: (port_) {
|
||||||
|
final serializer = SseSerializer(generalizedFrbRustBinding);
|
||||||
|
sse_encode_list_prim_u_8_loose(mediaKey, serializer);
|
||||||
|
sse_encode_String(addition, serializer);
|
||||||
|
pdeCallFfi(
|
||||||
|
generalizedFrbRustBinding,
|
||||||
|
serializer,
|
||||||
|
funcId: 19,
|
||||||
|
port: port_,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
codec: SseCodec(
|
||||||
|
decodeSuccessData: sse_decode_list_prim_u_8_strict,
|
||||||
|
decodeErrorData: sse_decode_AnyhowException,
|
||||||
|
),
|
||||||
|
constMeta:
|
||||||
|
kCrateBridgeWrapperKeyManagerRustKeyManagerEncryptCloudMediaKeyConstMeta,
|
||||||
|
argValues: [mediaKey, addition],
|
||||||
|
apiImpl: this,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TaskConstMeta
|
||||||
|
get kCrateBridgeWrapperKeyManagerRustKeyManagerEncryptCloudMediaKeyConstMeta =>
|
||||||
|
const TaskConstMeta(
|
||||||
|
debugName: "rust_key_manager_encrypt_cloud_media_key",
|
||||||
|
argNames: ["mediaKey", "addition"],
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Uint8List> crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken() {
|
Future<Uint8List> crateBridgeWrapperKeyManagerRustKeyManagerGetLoginToken() {
|
||||||
return handler.executeNormal(
|
return handler.executeNormal(
|
||||||
|
|
@ -1150,7 +1238,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 18,
|
funcId: 20,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1183,7 +1271,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 19,
|
funcId: 21,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1215,7 +1303,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 20,
|
funcId: 22,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1250,7 +1338,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 21,
|
funcId: 23,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1292,7 +1380,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 22,
|
funcId: 24,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1336,7 +1424,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 23,
|
funcId: 25,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1369,7 +1457,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 24,
|
funcId: 26,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1401,7 +1489,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 25,
|
funcId: 27,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1436,7 +1524,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 26,
|
funcId: 28,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1468,7 +1556,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 27,
|
funcId: 29,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1503,7 +1591,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 28,
|
funcId: 30,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1540,7 +1628,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 29,
|
funcId: 31,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1579,7 +1667,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 30,
|
funcId: 32,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1614,7 +1702,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 31,
|
funcId: 33,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1650,7 +1738,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 32,
|
funcId: 34,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1687,7 +1775,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 33,
|
funcId: 35,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1725,7 +1813,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 34,
|
funcId: 36,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1763,7 +1851,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 35,
|
funcId: 37,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1801,7 +1889,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 36,
|
funcId: 38,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1840,7 +1928,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 37,
|
funcId: 39,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1879,7 +1967,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 38,
|
funcId: 40,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1924,7 +2012,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 39,
|
funcId: 41,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -1976,7 +2064,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 40,
|
funcId: 42,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2017,7 +2105,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 41,
|
funcId: 43,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2055,7 +2143,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 42,
|
funcId: 44,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2093,7 +2181,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 43,
|
funcId: 45,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2131,7 +2219,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 44,
|
funcId: 46,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2169,7 +2257,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 45,
|
funcId: 47,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2211,7 +2299,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 46,
|
funcId: 48,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -2251,7 +2339,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
pdeCallFfi(
|
pdeCallFfi(
|
||||||
generalizedFrbRustBinding,
|
generalizedFrbRustBinding,
|
||||||
serializer,
|
serializer,
|
||||||
funcId: 47,
|
funcId: 49,
|
||||||
port: port_,
|
port: port_,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,6 @@ class AppState {
|
||||||
static bool isInBackgroundTask = false;
|
static bool isInBackgroundTask = false;
|
||||||
static bool allowErrorTrackingViaSentry = false;
|
static bool allowErrorTrackingViaSentry = false;
|
||||||
static bool gotMessageFromServer = false;
|
static bool gotMessageFromServer = false;
|
||||||
static int latestAppVersionId = 117;
|
static int latestAppVersionId = 118;
|
||||||
static bool hasCameraPermissions = false;
|
static bool hasCameraPermissions = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/services/api.service.dart';
|
import 'package:twonly/src/services/api/api.service.dart';
|
||||||
|
import 'package:twonly/src/services/news.service.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
|
|
||||||
final GetIt locator = GetIt.instance;
|
final GetIt locator = GetIt.instance;
|
||||||
|
|
@ -9,9 +10,11 @@ void setupLocator() {
|
||||||
locator
|
locator
|
||||||
..registerLazySingleton<UserService>(UserService.new)
|
..registerLazySingleton<UserService>(UserService.new)
|
||||||
..registerLazySingleton<ApiService>(ApiService.new)
|
..registerLazySingleton<ApiService>(ApiService.new)
|
||||||
..registerLazySingleton<TwonlyDB>(TwonlyDB.new);
|
..registerLazySingleton<TwonlyDB>(TwonlyDB.new)
|
||||||
|
..registerLazySingleton<NewsService>(NewsService.new);
|
||||||
}
|
}
|
||||||
|
|
||||||
UserService get userService => locator<UserService>();
|
UserService get userService => locator<UserService>();
|
||||||
ApiService get apiService => locator<ApiService>();
|
ApiService get apiService => locator<ApiService>();
|
||||||
TwonlyDB get twonlyDB => locator<TwonlyDB>();
|
TwonlyDB get twonlyDB => locator<TwonlyDB>();
|
||||||
|
NewsService get newsService => locator<NewsService>();
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,13 @@ Future<void> postStartupTasks() async {
|
||||||
// 2. Service initializations
|
// 2. Service initializations
|
||||||
unawaited(finishStartedPreprocessing());
|
unawaited(finishStartedPreprocessing());
|
||||||
unawaited(createPushAvatars());
|
unawaited(createPushAvatars());
|
||||||
|
unawaited(newsService.init().then((_) {
|
||||||
|
final lastDownload = newsService.lastDownloadedAt;
|
||||||
|
if (lastDownload == null ||
|
||||||
|
DateTime.now().difference(lastDownload) >= const Duration(days: 7)) {
|
||||||
|
newsService.fetchFeed();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
unawaited(UserDiscoveryService.verifyInitializationOnStartup());
|
unawaited(UserDiscoveryService.verifyInitializationOnStartup());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ class Routes {
|
||||||
static const String settingsHelpFaqVerifyBadge =
|
static const String settingsHelpFaqVerifyBadge =
|
||||||
'/settings/help/faq/verifybadge';
|
'/settings/help/faq/verifybadge';
|
||||||
static const String settingsHelpContactUs = '/settings/help/contact_us';
|
static const String settingsHelpContactUs = '/settings/help/contact_us';
|
||||||
|
static const String settingsHelpNews = '/settings/help/news';
|
||||||
static const String settingsHelpDiagnostics = '/settings/help/diagnostics';
|
static const String settingsHelpDiagnostics = '/settings/help/diagnostics';
|
||||||
static const String settingsHelpUserStudy = '/settings/help/user_study';
|
static const String settingsHelpUserStudy = '/settings/help/user_study';
|
||||||
static const String settingsHelpUserStudyQuestionnaire =
|
static const String settingsHelpUserStudyQuestionnaire =
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:hashlib/random.dart';
|
import 'package:hashlib/random.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/tables/messages.table.dart';
|
import 'package:twonly/src/database/tables/messages.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
part 'mediafiles.dao.g.dart';
|
part 'mediafiles.dao.g.dart';
|
||||||
|
|
@ -120,6 +123,7 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
(t) =>
|
(t) =>
|
||||||
t.stored.equals(true) &
|
t.stored.equals(true) &
|
||||||
(t.storedFileHash.isNull() |
|
(t.storedFileHash.isNull() |
|
||||||
|
t.blurhash.isNull() |
|
||||||
t.hasCropAnalyzed.equals(false) |
|
t.hasCropAnalyzed.equals(false) |
|
||||||
(t.hasThumbnail.equals(false) &
|
(t.hasThumbnail.equals(false) &
|
||||||
t.type.equals(MediaType.audio.name).not()) |
|
t.type.equals(MediaType.audio.name).not()) |
|
||||||
|
|
@ -141,7 +145,12 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
|
|
||||||
Stream<List<MediaFile>> watchAllStoredMediaFiles() {
|
Stream<List<MediaFile>> watchAllStoredMediaFiles() {
|
||||||
final query =
|
final query =
|
||||||
(select(mediaFiles)..where((t) => t.stored.equals(true))).join([])
|
(select(mediaFiles)..where(
|
||||||
|
(t) =>
|
||||||
|
t.stored.equals(true) |
|
||||||
|
t.cloudState.equals(CloudState.uploaded.name),
|
||||||
|
))
|
||||||
|
.join([])
|
||||||
..groupBy([
|
..groupBy([
|
||||||
const CustomExpression<Object>(
|
const CustomExpression<Object>(
|
||||||
'COALESCE(stored_file_hash, media_id)',
|
'COALESCE(stored_file_hash, media_id)',
|
||||||
|
|
@ -211,12 +220,59 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
||||||
final rows = await select(mediaFiles).get();
|
final rows = await select(mediaFiles).get();
|
||||||
final stats = <MediaType, int>{};
|
final stats = <MediaType, int>{};
|
||||||
|
|
||||||
|
final Set<String> existingPaths;
|
||||||
|
if (rows.isNotEmpty) {
|
||||||
|
final dummyMs = MediaFileService(rows.first);
|
||||||
|
final storedDir = dummyMs.storedPath.parent;
|
||||||
|
if (storedDir.existsSync()) {
|
||||||
|
existingPaths = storedDir
|
||||||
|
.listSync()
|
||||||
|
.whereType<File>()
|
||||||
|
.map((f) => f.path)
|
||||||
|
.toSet();
|
||||||
|
} else {
|
||||||
|
existingPaths = <String>{};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
existingPaths = <String>{};
|
||||||
|
}
|
||||||
|
|
||||||
for (final row in rows) {
|
for (final row in rows) {
|
||||||
final type = row.type;
|
final type = row.type;
|
||||||
final size = row.sizeInBytes ?? 0;
|
final ms = MediaFileService(row);
|
||||||
stats[type] = (stats[type] ?? 0) + size;
|
if (existingPaths.contains(ms.storedPath.path)) {
|
||||||
|
final size = row.sizeInBytes ?? 0;
|
||||||
|
stats[type] = (stats[type] ?? 0) + size;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<MediaFile>> getMemoriesToBackup() async {
|
||||||
|
return (select(mediaFiles)..where(
|
||||||
|
(t) =>
|
||||||
|
t.stored.equals(true) & t.cloudState.equals(CloudState.none.name),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> getCloudOnlyMemoriesCount() async {
|
||||||
|
final query = select(mediaFiles)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.stored.equals(true) &
|
||||||
|
t.cloudState.equals(CloudState.uploaded.name),
|
||||||
|
);
|
||||||
|
final rows = await query.get();
|
||||||
|
|
||||||
|
var count = 0;
|
||||||
|
for (final row in rows) {
|
||||||
|
final ms = MediaFileService(row);
|
||||||
|
if (!(ms.storedPath.existsSync() && ms.storedPath.lengthSync() > 0)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3161
lib/src/database/schemas/twonly_db/drift_schema_v23.json
Normal file
3161
lib/src/database/schemas/twonly_db/drift_schema_v23.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,12 @@ enum MediaType {
|
||||||
audio,
|
audio,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum CloudState {
|
||||||
|
none,
|
||||||
|
pending,
|
||||||
|
uploaded,
|
||||||
|
}
|
||||||
|
|
||||||
enum UploadState {
|
enum UploadState {
|
||||||
// Image/Video was taken. A database entry was created to track it...
|
// Image/Video was taken. A database entry was created to track it...
|
||||||
initialized,
|
initialized,
|
||||||
|
|
@ -43,6 +49,9 @@ class MediaFiles extends Table {
|
||||||
TextColumn get type => textEnum<MediaType>()();
|
TextColumn get type => textEnum<MediaType>()();
|
||||||
|
|
||||||
TextColumn get uploadState => textEnum<UploadState>().nullable()();
|
TextColumn get uploadState => textEnum<UploadState>().nullable()();
|
||||||
|
TextColumn get cloudState =>
|
||||||
|
textEnum<CloudState>().withDefault(const Constant('none'))();
|
||||||
|
TextColumn get blurhash => text().nullable()();
|
||||||
TextColumn get downloadState => textEnum<DownloadState>().nullable()();
|
TextColumn get downloadState => textEnum<DownloadState>().nullable()();
|
||||||
|
|
||||||
BoolColumn get requiresAuthentication =>
|
BoolColumn get requiresAuthentication =>
|
||||||
|
|
@ -69,15 +78,13 @@ class MediaFiles extends Table {
|
||||||
|
|
||||||
BlobColumn get storedFileHash => blob().nullable()();
|
BlobColumn get storedFileHash => blob().nullable()();
|
||||||
|
|
||||||
BoolColumn get hasThumbnail =>
|
BoolColumn get hasThumbnail => boolean().withDefault(const Constant(false))();
|
||||||
boolean().withDefault(const Constant(false))();
|
|
||||||
|
|
||||||
IntColumn get sizeInBytes => integer().nullable()();
|
IntColumn get sizeInBytes => integer().nullable()();
|
||||||
|
|
||||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||||
TextColumn get createdAtMonth => text().nullable()();
|
TextColumn get createdAtMonth => text().nullable()();
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<Column> get primaryKey => {mediaId};
|
Set<Column> get primaryKey => {mediaId};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class TwonlyDB extends _$TwonlyDB {
|
||||||
TwonlyDB.forTesting(DatabaseConnection super.connection);
|
TwonlyDB.forTesting(DatabaseConnection super.connection);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 22;
|
int get schemaVersion => 23;
|
||||||
|
|
||||||
static QueryExecutor _openConnection() {
|
static QueryExecutor _openConnection() {
|
||||||
final connection = driftDatabase(
|
final connection = driftDatabase(
|
||||||
|
|
@ -275,6 +275,16 @@ class TwonlyDB extends _$TwonlyDB {
|
||||||
schema.contacts.recoveryContactsThreshold,
|
schema.contacts.recoveryContactsThreshold,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
from22To23: (m, schema) async {
|
||||||
|
await m.addColumn(
|
||||||
|
schema.mediaFiles,
|
||||||
|
schema.mediaFiles.cloudState,
|
||||||
|
);
|
||||||
|
await m.addColumn(
|
||||||
|
schema.mediaFiles,
|
||||||
|
schema.mediaFiles.blurhash,
|
||||||
|
);
|
||||||
|
},
|
||||||
)(m, from, to);
|
)(m, from, to);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3094,6 +3094,27 @@ class $MediaFilesTable extends MediaFiles
|
||||||
requiredDuringInsert: false,
|
requiredDuringInsert: false,
|
||||||
).withConverter<UploadState?>($MediaFilesTable.$converteruploadStaten);
|
).withConverter<UploadState?>($MediaFilesTable.$converteruploadStaten);
|
||||||
@override
|
@override
|
||||||
|
late final GeneratedColumnWithTypeConverter<CloudState, String> cloudState =
|
||||||
|
GeneratedColumn<String>(
|
||||||
|
'cloud_state',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
defaultValue: const Constant('none'),
|
||||||
|
).withConverter<CloudState>($MediaFilesTable.$convertercloudState);
|
||||||
|
static const VerificationMeta _blurhashMeta = const VerificationMeta(
|
||||||
|
'blurhash',
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
late final GeneratedColumn<String> blurhash = GeneratedColumn<String>(
|
||||||
|
'blurhash',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: DriftSqlType.string,
|
||||||
|
requiredDuringInsert: false,
|
||||||
|
);
|
||||||
|
@override
|
||||||
late final GeneratedColumnWithTypeConverter<DownloadState?, String>
|
late final GeneratedColumnWithTypeConverter<DownloadState?, String>
|
||||||
downloadState = GeneratedColumn<String>(
|
downloadState = GeneratedColumn<String>(
|
||||||
'download_state',
|
'download_state',
|
||||||
|
|
@ -3333,6 +3354,8 @@ class $MediaFilesTable extends MediaFiles
|
||||||
mediaId,
|
mediaId,
|
||||||
type,
|
type,
|
||||||
uploadState,
|
uploadState,
|
||||||
|
cloudState,
|
||||||
|
blurhash,
|
||||||
downloadState,
|
downloadState,
|
||||||
requiresAuthentication,
|
requiresAuthentication,
|
||||||
stored,
|
stored,
|
||||||
|
|
@ -3373,6 +3396,12 @@ class $MediaFilesTable extends MediaFiles
|
||||||
} else if (isInserting) {
|
} else if (isInserting) {
|
||||||
context.missing(_mediaIdMeta);
|
context.missing(_mediaIdMeta);
|
||||||
}
|
}
|
||||||
|
if (data.containsKey('blurhash')) {
|
||||||
|
context.handle(
|
||||||
|
_blurhashMeta,
|
||||||
|
blurhash.isAcceptableOrUnknown(data['blurhash']!, _blurhashMeta),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (data.containsKey('requires_authentication')) {
|
if (data.containsKey('requires_authentication')) {
|
||||||
context.handle(
|
context.handle(
|
||||||
_requiresAuthenticationMeta,
|
_requiresAuthenticationMeta,
|
||||||
|
|
@ -3542,6 +3571,16 @@ class $MediaFilesTable extends MediaFiles
|
||||||
data['${effectivePrefix}upload_state'],
|
data['${effectivePrefix}upload_state'],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
cloudState: $MediaFilesTable.$convertercloudState.fromSql(
|
||||||
|
attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}cloud_state'],
|
||||||
|
)!,
|
||||||
|
),
|
||||||
|
blurhash: attachedDatabase.typeMapping.read(
|
||||||
|
DriftSqlType.string,
|
||||||
|
data['${effectivePrefix}blurhash'],
|
||||||
|
),
|
||||||
downloadState: $MediaFilesTable.$converterdownloadStaten.fromSql(
|
downloadState: $MediaFilesTable.$converterdownloadStaten.fromSql(
|
||||||
attachedDatabase.typeMapping.read(
|
attachedDatabase.typeMapping.read(
|
||||||
DriftSqlType.string,
|
DriftSqlType.string,
|
||||||
|
|
@ -3637,6 +3676,8 @@ class $MediaFilesTable extends MediaFiles
|
||||||
const EnumNameConverter<UploadState>(UploadState.values);
|
const EnumNameConverter<UploadState>(UploadState.values);
|
||||||
static JsonTypeConverter2<UploadState?, String?, String?>
|
static JsonTypeConverter2<UploadState?, String?, String?>
|
||||||
$converteruploadStaten = JsonTypeConverter2.asNullable($converteruploadState);
|
$converteruploadStaten = JsonTypeConverter2.asNullable($converteruploadState);
|
||||||
|
static JsonTypeConverter2<CloudState, String, String> $convertercloudState =
|
||||||
|
const EnumNameConverter<CloudState>(CloudState.values);
|
||||||
static JsonTypeConverter2<DownloadState, String, String>
|
static JsonTypeConverter2<DownloadState, String, String>
|
||||||
$converterdownloadState = const EnumNameConverter<DownloadState>(
|
$converterdownloadState = const EnumNameConverter<DownloadState>(
|
||||||
DownloadState.values,
|
DownloadState.values,
|
||||||
|
|
@ -3655,6 +3696,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
final String mediaId;
|
final String mediaId;
|
||||||
final MediaType type;
|
final MediaType type;
|
||||||
final UploadState? uploadState;
|
final UploadState? uploadState;
|
||||||
|
final CloudState cloudState;
|
||||||
|
final String? blurhash;
|
||||||
final DownloadState? downloadState;
|
final DownloadState? downloadState;
|
||||||
final bool requiresAuthentication;
|
final bool requiresAuthentication;
|
||||||
final bool stored;
|
final bool stored;
|
||||||
|
|
@ -3678,6 +3721,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
required this.mediaId,
|
required this.mediaId,
|
||||||
required this.type,
|
required this.type,
|
||||||
this.uploadState,
|
this.uploadState,
|
||||||
|
required this.cloudState,
|
||||||
|
this.blurhash,
|
||||||
this.downloadState,
|
this.downloadState,
|
||||||
required this.requiresAuthentication,
|
required this.requiresAuthentication,
|
||||||
required this.stored,
|
required this.stored,
|
||||||
|
|
@ -3712,6 +3757,14 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
$MediaFilesTable.$converteruploadStaten.toSql(uploadState),
|
$MediaFilesTable.$converteruploadStaten.toSql(uploadState),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
map['cloud_state'] = Variable<String>(
|
||||||
|
$MediaFilesTable.$convertercloudState.toSql(cloudState),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!nullToAbsent || blurhash != null) {
|
||||||
|
map['blurhash'] = Variable<String>(blurhash);
|
||||||
|
}
|
||||||
if (!nullToAbsent || downloadState != null) {
|
if (!nullToAbsent || downloadState != null) {
|
||||||
map['download_state'] = Variable<String>(
|
map['download_state'] = Variable<String>(
|
||||||
$MediaFilesTable.$converterdownloadStaten.toSql(downloadState),
|
$MediaFilesTable.$converterdownloadStaten.toSql(downloadState),
|
||||||
|
|
@ -3773,6 +3826,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
uploadState: uploadState == null && nullToAbsent
|
uploadState: uploadState == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(uploadState),
|
: Value(uploadState),
|
||||||
|
cloudState: Value(cloudState),
|
||||||
|
blurhash: blurhash == null && nullToAbsent
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(blurhash),
|
||||||
downloadState: downloadState == null && nullToAbsent
|
downloadState: downloadState == null && nullToAbsent
|
||||||
? const Value.absent()
|
? const Value.absent()
|
||||||
: Value(downloadState),
|
: Value(downloadState),
|
||||||
|
|
@ -3833,6 +3890,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
uploadState: $MediaFilesTable.$converteruploadStaten.fromJson(
|
uploadState: $MediaFilesTable.$converteruploadStaten.fromJson(
|
||||||
serializer.fromJson<String?>(json['uploadState']),
|
serializer.fromJson<String?>(json['uploadState']),
|
||||||
),
|
),
|
||||||
|
cloudState: $MediaFilesTable.$convertercloudState.fromJson(
|
||||||
|
serializer.fromJson<String>(json['cloudState']),
|
||||||
|
),
|
||||||
|
blurhash: serializer.fromJson<String?>(json['blurhash']),
|
||||||
downloadState: $MediaFilesTable.$converterdownloadStaten.fromJson(
|
downloadState: $MediaFilesTable.$converterdownloadStaten.fromJson(
|
||||||
serializer.fromJson<String?>(json['downloadState']),
|
serializer.fromJson<String?>(json['downloadState']),
|
||||||
),
|
),
|
||||||
|
|
@ -3875,6 +3936,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
'uploadState': serializer.toJson<String?>(
|
'uploadState': serializer.toJson<String?>(
|
||||||
$MediaFilesTable.$converteruploadStaten.toJson(uploadState),
|
$MediaFilesTable.$converteruploadStaten.toJson(uploadState),
|
||||||
),
|
),
|
||||||
|
'cloudState': serializer.toJson<String>(
|
||||||
|
$MediaFilesTable.$convertercloudState.toJson(cloudState),
|
||||||
|
),
|
||||||
|
'blurhash': serializer.toJson<String?>(blurhash),
|
||||||
'downloadState': serializer.toJson<String?>(
|
'downloadState': serializer.toJson<String?>(
|
||||||
$MediaFilesTable.$converterdownloadStaten.toJson(downloadState),
|
$MediaFilesTable.$converterdownloadStaten.toJson(downloadState),
|
||||||
),
|
),
|
||||||
|
|
@ -3905,6 +3970,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
String? mediaId,
|
String? mediaId,
|
||||||
MediaType? type,
|
MediaType? type,
|
||||||
Value<UploadState?> uploadState = const Value.absent(),
|
Value<UploadState?> uploadState = const Value.absent(),
|
||||||
|
CloudState? cloudState,
|
||||||
|
Value<String?> blurhash = const Value.absent(),
|
||||||
Value<DownloadState?> downloadState = const Value.absent(),
|
Value<DownloadState?> downloadState = const Value.absent(),
|
||||||
bool? requiresAuthentication,
|
bool? requiresAuthentication,
|
||||||
bool? stored,
|
bool? stored,
|
||||||
|
|
@ -3928,6 +3995,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
mediaId: mediaId ?? this.mediaId,
|
mediaId: mediaId ?? this.mediaId,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
uploadState: uploadState.present ? uploadState.value : this.uploadState,
|
uploadState: uploadState.present ? uploadState.value : this.uploadState,
|
||||||
|
cloudState: cloudState ?? this.cloudState,
|
||||||
|
blurhash: blurhash.present ? blurhash.value : this.blurhash,
|
||||||
downloadState: downloadState.present
|
downloadState: downloadState.present
|
||||||
? downloadState.value
|
? downloadState.value
|
||||||
: this.downloadState,
|
: this.downloadState,
|
||||||
|
|
@ -3976,6 +4045,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
uploadState: data.uploadState.present
|
uploadState: data.uploadState.present
|
||||||
? data.uploadState.value
|
? data.uploadState.value
|
||||||
: this.uploadState,
|
: this.uploadState,
|
||||||
|
cloudState: data.cloudState.present
|
||||||
|
? data.cloudState.value
|
||||||
|
: this.cloudState,
|
||||||
|
blurhash: data.blurhash.present ? data.blurhash.value : this.blurhash,
|
||||||
downloadState: data.downloadState.present
|
downloadState: data.downloadState.present
|
||||||
? data.downloadState.value
|
? data.downloadState.value
|
||||||
: this.downloadState,
|
: this.downloadState,
|
||||||
|
|
@ -4038,6 +4111,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
..write('mediaId: $mediaId, ')
|
..write('mediaId: $mediaId, ')
|
||||||
..write('type: $type, ')
|
..write('type: $type, ')
|
||||||
..write('uploadState: $uploadState, ')
|
..write('uploadState: $uploadState, ')
|
||||||
|
..write('cloudState: $cloudState, ')
|
||||||
|
..write('blurhash: $blurhash, ')
|
||||||
..write('downloadState: $downloadState, ')
|
..write('downloadState: $downloadState, ')
|
||||||
..write('requiresAuthentication: $requiresAuthentication, ')
|
..write('requiresAuthentication: $requiresAuthentication, ')
|
||||||
..write('stored: $stored, ')
|
..write('stored: $stored, ')
|
||||||
|
|
@ -4066,6 +4141,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
mediaId,
|
mediaId,
|
||||||
type,
|
type,
|
||||||
uploadState,
|
uploadState,
|
||||||
|
cloudState,
|
||||||
|
blurhash,
|
||||||
downloadState,
|
downloadState,
|
||||||
requiresAuthentication,
|
requiresAuthentication,
|
||||||
stored,
|
stored,
|
||||||
|
|
@ -4093,6 +4170,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
||||||
other.mediaId == this.mediaId &&
|
other.mediaId == this.mediaId &&
|
||||||
other.type == this.type &&
|
other.type == this.type &&
|
||||||
other.uploadState == this.uploadState &&
|
other.uploadState == this.uploadState &&
|
||||||
|
other.cloudState == this.cloudState &&
|
||||||
|
other.blurhash == this.blurhash &&
|
||||||
other.downloadState == this.downloadState &&
|
other.downloadState == this.downloadState &&
|
||||||
other.requiresAuthentication == this.requiresAuthentication &&
|
other.requiresAuthentication == this.requiresAuthentication &&
|
||||||
other.stored == this.stored &&
|
other.stored == this.stored &&
|
||||||
|
|
@ -4124,6 +4203,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
final Value<String> mediaId;
|
final Value<String> mediaId;
|
||||||
final Value<MediaType> type;
|
final Value<MediaType> type;
|
||||||
final Value<UploadState?> uploadState;
|
final Value<UploadState?> uploadState;
|
||||||
|
final Value<CloudState> cloudState;
|
||||||
|
final Value<String?> blurhash;
|
||||||
final Value<DownloadState?> downloadState;
|
final Value<DownloadState?> downloadState;
|
||||||
final Value<bool> requiresAuthentication;
|
final Value<bool> requiresAuthentication;
|
||||||
final Value<bool> stored;
|
final Value<bool> stored;
|
||||||
|
|
@ -4148,6 +4229,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
this.mediaId = const Value.absent(),
|
this.mediaId = const Value.absent(),
|
||||||
this.type = const Value.absent(),
|
this.type = const Value.absent(),
|
||||||
this.uploadState = const Value.absent(),
|
this.uploadState = const Value.absent(),
|
||||||
|
this.cloudState = const Value.absent(),
|
||||||
|
this.blurhash = const Value.absent(),
|
||||||
this.downloadState = const Value.absent(),
|
this.downloadState = const Value.absent(),
|
||||||
this.requiresAuthentication = const Value.absent(),
|
this.requiresAuthentication = const Value.absent(),
|
||||||
this.stored = const Value.absent(),
|
this.stored = const Value.absent(),
|
||||||
|
|
@ -4173,6 +4256,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
required String mediaId,
|
required String mediaId,
|
||||||
required MediaType type,
|
required MediaType type,
|
||||||
this.uploadState = const Value.absent(),
|
this.uploadState = const Value.absent(),
|
||||||
|
this.cloudState = const Value.absent(),
|
||||||
|
this.blurhash = const Value.absent(),
|
||||||
this.downloadState = const Value.absent(),
|
this.downloadState = const Value.absent(),
|
||||||
this.requiresAuthentication = const Value.absent(),
|
this.requiresAuthentication = const Value.absent(),
|
||||||
this.stored = const Value.absent(),
|
this.stored = const Value.absent(),
|
||||||
|
|
@ -4199,6 +4284,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
Expression<String>? mediaId,
|
Expression<String>? mediaId,
|
||||||
Expression<String>? type,
|
Expression<String>? type,
|
||||||
Expression<String>? uploadState,
|
Expression<String>? uploadState,
|
||||||
|
Expression<String>? cloudState,
|
||||||
|
Expression<String>? blurhash,
|
||||||
Expression<String>? downloadState,
|
Expression<String>? downloadState,
|
||||||
Expression<bool>? requiresAuthentication,
|
Expression<bool>? requiresAuthentication,
|
||||||
Expression<bool>? stored,
|
Expression<bool>? stored,
|
||||||
|
|
@ -4224,6 +4311,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
if (mediaId != null) 'media_id': mediaId,
|
if (mediaId != null) 'media_id': mediaId,
|
||||||
if (type != null) 'type': type,
|
if (type != null) 'type': type,
|
||||||
if (uploadState != null) 'upload_state': uploadState,
|
if (uploadState != null) 'upload_state': uploadState,
|
||||||
|
if (cloudState != null) 'cloud_state': cloudState,
|
||||||
|
if (blurhash != null) 'blurhash': blurhash,
|
||||||
if (downloadState != null) 'download_state': downloadState,
|
if (downloadState != null) 'download_state': downloadState,
|
||||||
if (requiresAuthentication != null)
|
if (requiresAuthentication != null)
|
||||||
'requires_authentication': requiresAuthentication,
|
'requires_authentication': requiresAuthentication,
|
||||||
|
|
@ -4255,6 +4344,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
Value<String>? mediaId,
|
Value<String>? mediaId,
|
||||||
Value<MediaType>? type,
|
Value<MediaType>? type,
|
||||||
Value<UploadState?>? uploadState,
|
Value<UploadState?>? uploadState,
|
||||||
|
Value<CloudState>? cloudState,
|
||||||
|
Value<String?>? blurhash,
|
||||||
Value<DownloadState?>? downloadState,
|
Value<DownloadState?>? downloadState,
|
||||||
Value<bool>? requiresAuthentication,
|
Value<bool>? requiresAuthentication,
|
||||||
Value<bool>? stored,
|
Value<bool>? stored,
|
||||||
|
|
@ -4280,6 +4371,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
mediaId: mediaId ?? this.mediaId,
|
mediaId: mediaId ?? this.mediaId,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
uploadState: uploadState ?? this.uploadState,
|
uploadState: uploadState ?? this.uploadState,
|
||||||
|
cloudState: cloudState ?? this.cloudState,
|
||||||
|
blurhash: blurhash ?? this.blurhash,
|
||||||
downloadState: downloadState ?? this.downloadState,
|
downloadState: downloadState ?? this.downloadState,
|
||||||
requiresAuthentication:
|
requiresAuthentication:
|
||||||
requiresAuthentication ?? this.requiresAuthentication,
|
requiresAuthentication ?? this.requiresAuthentication,
|
||||||
|
|
@ -4322,6 +4415,14 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
$MediaFilesTable.$converteruploadStaten.toSql(uploadState.value),
|
$MediaFilesTable.$converteruploadStaten.toSql(uploadState.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (cloudState.present) {
|
||||||
|
map['cloud_state'] = Variable<String>(
|
||||||
|
$MediaFilesTable.$convertercloudState.toSql(cloudState.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (blurhash.present) {
|
||||||
|
map['blurhash'] = Variable<String>(blurhash.value);
|
||||||
|
}
|
||||||
if (downloadState.present) {
|
if (downloadState.present) {
|
||||||
map['download_state'] = Variable<String>(
|
map['download_state'] = Variable<String>(
|
||||||
$MediaFilesTable.$converterdownloadStaten.toSql(downloadState.value),
|
$MediaFilesTable.$converterdownloadStaten.toSql(downloadState.value),
|
||||||
|
|
@ -4403,6 +4504,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||||
..write('mediaId: $mediaId, ')
|
..write('mediaId: $mediaId, ')
|
||||||
..write('type: $type, ')
|
..write('type: $type, ')
|
||||||
..write('uploadState: $uploadState, ')
|
..write('uploadState: $uploadState, ')
|
||||||
|
..write('cloudState: $cloudState, ')
|
||||||
|
..write('blurhash: $blurhash, ')
|
||||||
..write('downloadState: $downloadState, ')
|
..write('downloadState: $downloadState, ')
|
||||||
..write('requiresAuthentication: $requiresAuthentication, ')
|
..write('requiresAuthentication: $requiresAuthentication, ')
|
||||||
..write('stored: $stored, ')
|
..write('stored: $stored, ')
|
||||||
|
|
@ -16080,6 +16183,8 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
|
||||||
required String mediaId,
|
required String mediaId,
|
||||||
required MediaType type,
|
required MediaType type,
|
||||||
Value<UploadState?> uploadState,
|
Value<UploadState?> uploadState,
|
||||||
|
Value<CloudState> cloudState,
|
||||||
|
Value<String?> blurhash,
|
||||||
Value<DownloadState?> downloadState,
|
Value<DownloadState?> downloadState,
|
||||||
Value<bool> requiresAuthentication,
|
Value<bool> requiresAuthentication,
|
||||||
Value<bool> stored,
|
Value<bool> stored,
|
||||||
|
|
@ -16106,6 +16211,8 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
|
||||||
Value<String> mediaId,
|
Value<String> mediaId,
|
||||||
Value<MediaType> type,
|
Value<MediaType> type,
|
||||||
Value<UploadState?> uploadState,
|
Value<UploadState?> uploadState,
|
||||||
|
Value<CloudState> cloudState,
|
||||||
|
Value<String?> blurhash,
|
||||||
Value<DownloadState?> downloadState,
|
Value<DownloadState?> downloadState,
|
||||||
Value<bool> requiresAuthentication,
|
Value<bool> requiresAuthentication,
|
||||||
Value<bool> stored,
|
Value<bool> stored,
|
||||||
|
|
@ -16177,6 +16284,17 @@ class $$MediaFilesTableFilterComposer
|
||||||
builder: (column) => ColumnWithTypeConverterFilters(column),
|
builder: (column) => ColumnWithTypeConverterFilters(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnWithTypeConverterFilters<CloudState, CloudState, String>
|
||||||
|
get cloudState => $composableBuilder(
|
||||||
|
column: $table.cloudState,
|
||||||
|
builder: (column) => ColumnWithTypeConverterFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnFilters<String> get blurhash => $composableBuilder(
|
||||||
|
column: $table.blurhash,
|
||||||
|
builder: (column) => ColumnFilters(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnWithTypeConverterFilters<DownloadState?, DownloadState, String>
|
ColumnWithTypeConverterFilters<DownloadState?, DownloadState, String>
|
||||||
get downloadState => $composableBuilder(
|
get downloadState => $composableBuilder(
|
||||||
column: $table.downloadState,
|
column: $table.downloadState,
|
||||||
|
|
@ -16324,6 +16442,16 @@ class $$MediaFilesTableOrderingComposer
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get cloudState => $composableBuilder(
|
||||||
|
column: $table.cloudState,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
|
ColumnOrderings<String> get blurhash => $composableBuilder(
|
||||||
|
column: $table.blurhash,
|
||||||
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
);
|
||||||
|
|
||||||
ColumnOrderings<String> get downloadState => $composableBuilder(
|
ColumnOrderings<String> get downloadState => $composableBuilder(
|
||||||
column: $table.downloadState,
|
column: $table.downloadState,
|
||||||
builder: (column) => ColumnOrderings(column),
|
builder: (column) => ColumnOrderings(column),
|
||||||
|
|
@ -16441,6 +16569,15 @@ class $$MediaFilesTableAnnotationComposer
|
||||||
builder: (column) => column,
|
builder: (column) => column,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
GeneratedColumnWithTypeConverter<CloudState, String> get cloudState =>
|
||||||
|
$composableBuilder(
|
||||||
|
column: $table.cloudState,
|
||||||
|
builder: (column) => column,
|
||||||
|
);
|
||||||
|
|
||||||
|
GeneratedColumn<String> get blurhash =>
|
||||||
|
$composableBuilder(column: $table.blurhash, builder: (column) => column);
|
||||||
|
|
||||||
GeneratedColumnWithTypeConverter<DownloadState?, String> get downloadState =>
|
GeneratedColumnWithTypeConverter<DownloadState?, String> get downloadState =>
|
||||||
$composableBuilder(
|
$composableBuilder(
|
||||||
column: $table.downloadState,
|
column: $table.downloadState,
|
||||||
|
|
@ -16591,6 +16728,8 @@ class $$MediaFilesTableTableManager
|
||||||
Value<String> mediaId = const Value.absent(),
|
Value<String> mediaId = const Value.absent(),
|
||||||
Value<MediaType> type = const Value.absent(),
|
Value<MediaType> type = const Value.absent(),
|
||||||
Value<UploadState?> uploadState = const Value.absent(),
|
Value<UploadState?> uploadState = const Value.absent(),
|
||||||
|
Value<CloudState> cloudState = const Value.absent(),
|
||||||
|
Value<String?> blurhash = const Value.absent(),
|
||||||
Value<DownloadState?> downloadState = const Value.absent(),
|
Value<DownloadState?> downloadState = const Value.absent(),
|
||||||
Value<bool> requiresAuthentication = const Value.absent(),
|
Value<bool> requiresAuthentication = const Value.absent(),
|
||||||
Value<bool> stored = const Value.absent(),
|
Value<bool> stored = const Value.absent(),
|
||||||
|
|
@ -16615,6 +16754,8 @@ class $$MediaFilesTableTableManager
|
||||||
mediaId: mediaId,
|
mediaId: mediaId,
|
||||||
type: type,
|
type: type,
|
||||||
uploadState: uploadState,
|
uploadState: uploadState,
|
||||||
|
cloudState: cloudState,
|
||||||
|
blurhash: blurhash,
|
||||||
downloadState: downloadState,
|
downloadState: downloadState,
|
||||||
requiresAuthentication: requiresAuthentication,
|
requiresAuthentication: requiresAuthentication,
|
||||||
stored: stored,
|
stored: stored,
|
||||||
|
|
@ -16641,6 +16782,8 @@ class $$MediaFilesTableTableManager
|
||||||
required String mediaId,
|
required String mediaId,
|
||||||
required MediaType type,
|
required MediaType type,
|
||||||
Value<UploadState?> uploadState = const Value.absent(),
|
Value<UploadState?> uploadState = const Value.absent(),
|
||||||
|
Value<CloudState> cloudState = const Value.absent(),
|
||||||
|
Value<String?> blurhash = const Value.absent(),
|
||||||
Value<DownloadState?> downloadState = const Value.absent(),
|
Value<DownloadState?> downloadState = const Value.absent(),
|
||||||
Value<bool> requiresAuthentication = const Value.absent(),
|
Value<bool> requiresAuthentication = const Value.absent(),
|
||||||
Value<bool> stored = const Value.absent(),
|
Value<bool> stored = const Value.absent(),
|
||||||
|
|
@ -16665,6 +16808,8 @@ class $$MediaFilesTableTableManager
|
||||||
mediaId: mediaId,
|
mediaId: mediaId,
|
||||||
type: type,
|
type: type,
|
||||||
uploadState: uploadState,
|
uploadState: uploadState,
|
||||||
|
cloudState: cloudState,
|
||||||
|
blurhash: blurhash,
|
||||||
downloadState: downloadState,
|
downloadState: downloadState,
|
||||||
requiresAuthentication: requiresAuthentication,
|
requiresAuthentication: requiresAuthentication,
|
||||||
stored: stored,
|
stored: stored,
|
||||||
|
|
|
||||||
|
|
@ -11600,6 +11600,539 @@ i1.GeneratedColumn<int> _column_254(String aliasedName) =>
|
||||||
type: i1.DriftSqlType.int,
|
type: i1.DriftSqlType.int,
|
||||||
$customConstraints: 'NULL',
|
$customConstraints: 'NULL',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final class Schema23 extends i0.VersionedSchema {
|
||||||
|
Schema23({required super.database}) : super(version: 23);
|
||||||
|
@override
|
||||||
|
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||||
|
contacts,
|
||||||
|
groups,
|
||||||
|
mediaFiles,
|
||||||
|
messages,
|
||||||
|
messageHistories,
|
||||||
|
reactions,
|
||||||
|
groupMembers,
|
||||||
|
receipts,
|
||||||
|
receivedReceipts,
|
||||||
|
signalIdentityKeyStores,
|
||||||
|
signalPreKeyStores,
|
||||||
|
signalSenderKeyStores,
|
||||||
|
signalSessionStores,
|
||||||
|
signalSignedPreKeyStores,
|
||||||
|
messageActions,
|
||||||
|
groupHistories,
|
||||||
|
keyVerifications,
|
||||||
|
verificationTokens,
|
||||||
|
userDiscoveryAnnouncedUsers,
|
||||||
|
userDiscoveryUserRelations,
|
||||||
|
userDiscoveryOtherPromotions,
|
||||||
|
userDiscoveryOwnPromotions,
|
||||||
|
userDiscoveryShares,
|
||||||
|
shortcuts,
|
||||||
|
shortcutMembers,
|
||||||
|
];
|
||||||
|
late final Shape57 contacts = Shape57(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'contacts',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(user_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_106,
|
||||||
|
_column_107,
|
||||||
|
_column_108,
|
||||||
|
_column_109,
|
||||||
|
_column_110,
|
||||||
|
_column_111,
|
||||||
|
_column_112,
|
||||||
|
_column_113,
|
||||||
|
_column_114,
|
||||||
|
_column_115,
|
||||||
|
_column_116,
|
||||||
|
_column_117,
|
||||||
|
_column_118,
|
||||||
|
_column_211,
|
||||||
|
_column_212,
|
||||||
|
_column_213,
|
||||||
|
_column_249,
|
||||||
|
_column_250,
|
||||||
|
_column_251,
|
||||||
|
_column_252,
|
||||||
|
_column_253,
|
||||||
|
_column_254,
|
||||||
|
_column_247,
|
||||||
|
_column_214,
|
||||||
|
_column_215,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape23 groups = Shape23(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'groups',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(group_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_119,
|
||||||
|
_column_120,
|
||||||
|
_column_121,
|
||||||
|
_column_122,
|
||||||
|
_column_123,
|
||||||
|
_column_124,
|
||||||
|
_column_125,
|
||||||
|
_column_126,
|
||||||
|
_column_127,
|
||||||
|
_column_128,
|
||||||
|
_column_129,
|
||||||
|
_column_130,
|
||||||
|
_column_131,
|
||||||
|
_column_132,
|
||||||
|
_column_133,
|
||||||
|
_column_134,
|
||||||
|
_column_118,
|
||||||
|
_column_135,
|
||||||
|
_column_136,
|
||||||
|
_column_137,
|
||||||
|
_column_138,
|
||||||
|
_column_139,
|
||||||
|
_column_140,
|
||||||
|
_column_141,
|
||||||
|
_column_142,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape58 mediaFiles = Shape58(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'media_files',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(media_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_143,
|
||||||
|
_column_144,
|
||||||
|
_column_145,
|
||||||
|
_column_255,
|
||||||
|
_column_256,
|
||||||
|
_column_146,
|
||||||
|
_column_147,
|
||||||
|
_column_148,
|
||||||
|
_column_149,
|
||||||
|
_column_239,
|
||||||
|
_column_240,
|
||||||
|
_column_207,
|
||||||
|
_column_150,
|
||||||
|
_column_151,
|
||||||
|
_column_152,
|
||||||
|
_column_153,
|
||||||
|
_column_154,
|
||||||
|
_column_155,
|
||||||
|
_column_156,
|
||||||
|
_column_157,
|
||||||
|
_column_244,
|
||||||
|
_column_245,
|
||||||
|
_column_118,
|
||||||
|
_column_241,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape25 messages = Shape25(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'messages',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(message_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_158,
|
||||||
|
_column_159,
|
||||||
|
_column_160,
|
||||||
|
_column_144,
|
||||||
|
_column_161,
|
||||||
|
_column_162,
|
||||||
|
_column_163,
|
||||||
|
_column_164,
|
||||||
|
_column_165,
|
||||||
|
_column_153,
|
||||||
|
_column_166,
|
||||||
|
_column_167,
|
||||||
|
_column_168,
|
||||||
|
_column_169,
|
||||||
|
_column_118,
|
||||||
|
_column_170,
|
||||||
|
_column_171,
|
||||||
|
_column_172,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape26 messageHistories = Shape26(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'message_histories',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_173,
|
||||||
|
_column_174,
|
||||||
|
_column_175,
|
||||||
|
_column_161,
|
||||||
|
_column_118,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape27 reactions = Shape27(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'reactions',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(message_id, sender_id, emoji)'],
|
||||||
|
columns: [_column_174, _column_176, _column_177, _column_118],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape38 groupMembers = Shape38(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'group_members',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(group_id, contact_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_158,
|
||||||
|
_column_178,
|
||||||
|
_column_179,
|
||||||
|
_column_180,
|
||||||
|
_column_209,
|
||||||
|
_column_210,
|
||||||
|
_column_181,
|
||||||
|
_column_118,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape37 receipts = Shape37(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'receipts',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(receipt_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_182,
|
||||||
|
_column_183,
|
||||||
|
_column_184,
|
||||||
|
_column_185,
|
||||||
|
_column_186,
|
||||||
|
_column_208,
|
||||||
|
_column_187,
|
||||||
|
_column_188,
|
||||||
|
_column_189,
|
||||||
|
_column_190,
|
||||||
|
_column_191,
|
||||||
|
_column_118,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape30 receivedReceipts = Shape30(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'received_receipts',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(receipt_id)'],
|
||||||
|
columns: [_column_182, _column_118],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape31 signalIdentityKeyStores = Shape31(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'signal_identity_key_stores',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(device_id, name)'],
|
||||||
|
columns: [_column_192, _column_193, _column_194, _column_118],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape32 signalPreKeyStores = Shape32(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'signal_pre_key_stores',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(pre_key_id)'],
|
||||||
|
columns: [_column_195, _column_196, _column_118],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape11 signalSenderKeyStores = Shape11(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'signal_sender_key_stores',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(sender_key_name)'],
|
||||||
|
columns: [_column_197, _column_198],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape33 signalSessionStores = Shape33(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'signal_session_stores',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(device_id, name)'],
|
||||||
|
columns: [_column_192, _column_193, _column_199, _column_118],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape50 signalSignedPreKeyStores = Shape50(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'signal_signed_pre_key_stores',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(signed_pre_key_id)'],
|
||||||
|
columns: [_column_242, _column_243, _column_118],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape34 messageActions = Shape34(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'message_actions',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(message_id, contact_id, type)'],
|
||||||
|
columns: [_column_174, _column_183, _column_144, _column_200],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape35 groupHistories = Shape35(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'group_histories',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(group_history_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_201,
|
||||||
|
_column_158,
|
||||||
|
_column_202,
|
||||||
|
_column_203,
|
||||||
|
_column_204,
|
||||||
|
_column_205,
|
||||||
|
_column_206,
|
||||||
|
_column_144,
|
||||||
|
_column_200,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape54 keyVerifications = Shape54(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'key_verifications',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [
|
||||||
|
_column_216,
|
||||||
|
_column_183,
|
||||||
|
_column_144,
|
||||||
|
_column_248,
|
||||||
|
_column_118,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape41 verificationTokens = Shape41(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'verification_tokens',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [_column_217, _column_218, _column_118],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape52 userDiscoveryAnnouncedUsers = Shape52(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_discovery_announced_users',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(announced_user_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_219,
|
||||||
|
_column_220,
|
||||||
|
_column_221,
|
||||||
|
_column_222,
|
||||||
|
_column_223,
|
||||||
|
_column_224,
|
||||||
|
_column_246,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape43 userDiscoveryUserRelations = Shape43(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_discovery_user_relations',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(announced_user_id, from_contact_id)'],
|
||||||
|
columns: [_column_225, _column_226, _column_227],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape44 userDiscoveryOtherPromotions = Shape44(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_discovery_other_promotions',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(from_contact_id, public_id)'],
|
||||||
|
columns: [
|
||||||
|
_column_226,
|
||||||
|
_column_228,
|
||||||
|
_column_229,
|
||||||
|
_column_230,
|
||||||
|
_column_231,
|
||||||
|
_column_227,
|
||||||
|
],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape45 userDiscoveryOwnPromotions = Shape45(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_discovery_own_promotions',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [_column_232, _column_183, _column_233],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape46 userDiscoveryShares = Shape46(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'user_discovery_shares',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [_column_234, _column_235, _column_175],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape47 shortcuts = Shape47(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'shortcuts',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: [],
|
||||||
|
columns: [_column_173, _column_236, _column_237],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
late final Shape48 shortcutMembers = Shape48(
|
||||||
|
source: i0.VersionedTable(
|
||||||
|
entityName: 'shortcut_members',
|
||||||
|
withoutRowId: false,
|
||||||
|
isStrict: false,
|
||||||
|
tableConstraints: ['PRIMARY KEY(shortcut_id, group_id)'],
|
||||||
|
columns: [_column_238, _column_158],
|
||||||
|
attachedDatabase: database,
|
||||||
|
),
|
||||||
|
alias: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Shape58 extends i0.VersionedTable {
|
||||||
|
Shape58({required super.source, required super.alias}) : super.aliased();
|
||||||
|
i1.GeneratedColumn<String> get mediaId =>
|
||||||
|
columnsByName['media_id']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get type =>
|
||||||
|
columnsByName['type']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get uploadState =>
|
||||||
|
columnsByName['upload_state']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get cloudState =>
|
||||||
|
columnsByName['cloud_state']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get blurhash =>
|
||||||
|
columnsByName['blurhash']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<String> get downloadState =>
|
||||||
|
columnsByName['download_state']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get requiresAuthentication =>
|
||||||
|
columnsByName['requires_authentication']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get stored =>
|
||||||
|
columnsByName['stored']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get isDraftMedia =>
|
||||||
|
columnsByName['is_draft_media']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get isFavorite =>
|
||||||
|
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get hasCropAnalyzed =>
|
||||||
|
columnsByName['has_crop_analyzed']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get preProgressingProcess =>
|
||||||
|
columnsByName['pre_progressing_process']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get reuploadRequestedBy =>
|
||||||
|
columnsByName['reupload_requested_by']! as i1.GeneratedColumn<String>;
|
||||||
|
i1.GeneratedColumn<int> get displayLimitInMilliseconds =>
|
||||||
|
columnsByName['display_limit_in_milliseconds']!
|
||||||
|
as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get removeAudio =>
|
||||||
|
columnsByName['remove_audio']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<i2.Uint8List> get downloadToken =>
|
||||||
|
columnsByName['download_token']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||||
|
i1.GeneratedColumn<i2.Uint8List> get encryptionKey =>
|
||||||
|
columnsByName['encryption_key']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||||
|
i1.GeneratedColumn<i2.Uint8List> get encryptionMac =>
|
||||||
|
columnsByName['encryption_mac']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||||
|
i1.GeneratedColumn<i2.Uint8List> get encryptionNonce =>
|
||||||
|
columnsByName['encryption_nonce']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||||
|
i1.GeneratedColumn<i2.Uint8List> get storedFileHash =>
|
||||||
|
columnsByName['stored_file_hash']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||||
|
i1.GeneratedColumn<int> get hasThumbnail =>
|
||||||
|
columnsByName['has_thumbnail']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get sizeInBytes =>
|
||||||
|
columnsByName['size_in_bytes']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<int> get createdAt =>
|
||||||
|
columnsByName['created_at']! as i1.GeneratedColumn<int>;
|
||||||
|
i1.GeneratedColumn<String> get createdAtMonth =>
|
||||||
|
columnsByName['created_at_month']! as i1.GeneratedColumn<String>;
|
||||||
|
}
|
||||||
|
|
||||||
|
i1.GeneratedColumn<String> _column_255(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>(
|
||||||
|
'cloud_state',
|
||||||
|
aliasedName,
|
||||||
|
false,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
$customConstraints: 'NOT NULL DEFAULT \'none\'',
|
||||||
|
defaultValue: const i1.CustomExpression('\'none\''),
|
||||||
|
);
|
||||||
|
i1.GeneratedColumn<String> _column_256(String aliasedName) =>
|
||||||
|
i1.GeneratedColumn<String>(
|
||||||
|
'blurhash',
|
||||||
|
aliasedName,
|
||||||
|
true,
|
||||||
|
type: i1.DriftSqlType.string,
|
||||||
|
$customConstraints: 'NULL',
|
||||||
|
);
|
||||||
i0.MigrationStepWithVersion migrationSteps({
|
i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||||
|
|
@ -11622,6 +12155,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||||
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
|
||||||
}) {
|
}) {
|
||||||
return (currentVersion, database) async {
|
return (currentVersion, database) async {
|
||||||
switch (currentVersion) {
|
switch (currentVersion) {
|
||||||
|
|
@ -11730,6 +12264,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
||||||
final migrator = i1.Migrator(database, schema);
|
final migrator = i1.Migrator(database, schema);
|
||||||
await from21To22(migrator, schema);
|
await from21To22(migrator, schema);
|
||||||
return 22;
|
return 22;
|
||||||
|
case 22:
|
||||||
|
final schema = Schema23(database: database);
|
||||||
|
final migrator = i1.Migrator(database, schema);
|
||||||
|
await from22To23(migrator, schema);
|
||||||
|
return 23;
|
||||||
default:
|
default:
|
||||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||||
}
|
}
|
||||||
|
|
@ -11758,6 +12297,7 @@ i1.OnUpgrade stepByStep({
|
||||||
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
required Future<void> Function(i1.Migrator m, Schema20 schema) from19To20,
|
||||||
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
required Future<void> Function(i1.Migrator m, Schema21 schema) from20To21,
|
||||||
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
required Future<void> Function(i1.Migrator m, Schema22 schema) from21To22,
|
||||||
|
required Future<void> Function(i1.Migrator m, Schema23 schema) from22To23,
|
||||||
}) => i0.VersionedSchema.stepByStepHelper(
|
}) => i0.VersionedSchema.stepByStepHelper(
|
||||||
step: migrationSteps(
|
step: migrationSteps(
|
||||||
from1To2: from1To2,
|
from1To2: from1To2,
|
||||||
|
|
@ -11781,5 +12321,6 @@ i1.OnUpgrade stepByStep({
|
||||||
from19To20: from19To20,
|
from19To20: from19To20,
|
||||||
from20To21: from20To21,
|
from20To21: from20To21,
|
||||||
from21To22: from21To22,
|
from21To22: from21To22,
|
||||||
|
from22To23: from22To23,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -806,11 +806,11 @@ abstract class AppLocalizations {
|
||||||
/// **'Feedback submitted successfully!'**
|
/// **'Feedback submitted successfully!'**
|
||||||
String get contactUsSuccess;
|
String get contactUsSuccess;
|
||||||
|
|
||||||
/// No description provided for @contactUsShortcut.
|
/// No description provided for @hideNewsIcon.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Hide Feedback Icon'**
|
/// **'Hide News Icon'**
|
||||||
String get contactUsShortcut;
|
String get hideNewsIcon;
|
||||||
|
|
||||||
/// No description provided for @settingsHelpTerms.
|
/// No description provided for @settingsHelpTerms.
|
||||||
///
|
///
|
||||||
|
|
@ -926,12 +926,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Verified via link.'**
|
/// **'Verified via link.'**
|
||||||
String get verificationTypeLink;
|
String get verificationTypeLink;
|
||||||
|
|
||||||
/// No description provided for @verificationTypeContactSharedByVerified.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Contact received from a verified contact.'**
|
|
||||||
String get verificationTypeContactSharedByVerified;
|
|
||||||
|
|
||||||
/// No description provided for @verificationTypeMigratedFromOldVersion.
|
/// No description provided for @verificationTypeMigratedFromOldVersion.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -1271,13 +1265,13 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @proFeature3.
|
/// No description provided for @proFeature3.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'✓ Restore flames'**
|
/// **'✓ 25GB Memories storage'**
|
||||||
String get proFeature3;
|
String get proFeature3;
|
||||||
|
|
||||||
/// No description provided for @proFeature4.
|
/// No description provided for @proFeature4.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'✓ Support twonly'**
|
/// **'✓ Restore flames'**
|
||||||
String get proFeature4;
|
String get proFeature4;
|
||||||
|
|
||||||
/// No description provided for @familyFeature1.
|
/// No description provided for @familyFeature1.
|
||||||
|
|
@ -1295,7 +1289,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @familyFeature3.
|
/// No description provided for @familyFeature3.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'✓ Restore flames'**
|
/// **'✓ 50GB Memories storage'**
|
||||||
String get familyFeature3;
|
String get familyFeature3;
|
||||||
|
|
||||||
/// No description provided for @familyFeature4.
|
/// No description provided for @familyFeature4.
|
||||||
|
|
@ -1448,6 +1442,60 @@ abstract class AppLocalizations {
|
||||||
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
|
/// **'{count, plural, =1 {The image will be irrevocably deleted.} other {The {count} images will be irrevocably deleted.}}'**
|
||||||
String deleteMemoriesBody(num count);
|
String deleteMemoriesBody(num count);
|
||||||
|
|
||||||
|
/// No description provided for @deleteMemoriesLocalOnly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Local Only'**
|
||||||
|
String get deleteMemoriesLocalOnly;
|
||||||
|
|
||||||
|
/// No description provided for @deleteMemoriesCompletely.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Completely'**
|
||||||
|
String get deleteMemoriesCompletely;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesSelectedCount.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'{count, plural, =1 {1 Element} other {{count} Elements}}'**
|
||||||
|
String memoriesSelectedCount(num count);
|
||||||
|
|
||||||
|
/// No description provided for @memoriesMenuSelectAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select all'**
|
||||||
|
String get memoriesMenuSelectAll;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesMenuDeselectAll.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Deselect all'**
|
||||||
|
String get memoriesMenuDeselectAll;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesMenuExport.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Export to gallery'**
|
||||||
|
String get memoriesMenuExport;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesMenuFavorite.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Favorite'**
|
||||||
|
String get memoriesMenuFavorite;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesMenuDelete.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete completely'**
|
||||||
|
String get memoriesMenuDelete;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesMenuDeleteLocal.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete locally'**
|
||||||
|
String get memoriesMenuDeleteLocal;
|
||||||
|
|
||||||
/// No description provided for @settingsBackup.
|
/// No description provided for @settingsBackup.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -1670,12 +1718,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Retransmission requested'**
|
/// **'Retransmission requested'**
|
||||||
String get retransmissionRequested;
|
String get retransmissionRequested;
|
||||||
|
|
||||||
/// No description provided for @openChangeLog.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Open changelog automatically'**
|
|
||||||
String get openChangeLog;
|
|
||||||
|
|
||||||
/// No description provided for @reportUserTitle.
|
/// No description provided for @reportUserTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2084,12 +2126,6 @@ abstract class AppLocalizations {
|
||||||
/// **'wants to connect with you.'**
|
/// **'wants to connect with you.'**
|
||||||
String get notificationContactRequest;
|
String get notificationContactRequest;
|
||||||
|
|
||||||
/// No description provided for @notificationContactRequestUnknownUser.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'have received a new contact request.'**
|
|
||||||
String get notificationContactRequestUnknownUser;
|
|
||||||
|
|
||||||
/// No description provided for @notificationAcceptRequest.
|
/// No description provided for @notificationAcceptRequest.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2144,18 +2180,6 @@ abstract class AppLocalizations {
|
||||||
/// **'has responded{inGroup}.'**
|
/// **'has responded{inGroup}.'**
|
||||||
String notificationResponse(Object inGroup);
|
String notificationResponse(Object inGroup);
|
||||||
|
|
||||||
/// No description provided for @notificationTitleUnknown.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'You have a new message.'**
|
|
||||||
String get notificationTitleUnknown;
|
|
||||||
|
|
||||||
/// No description provided for @notificationBodyUnknown.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Open twonly to learn more.'**
|
|
||||||
String get notificationBodyUnknown;
|
|
||||||
|
|
||||||
/// No description provided for @notificationCategoryMessageTitle.
|
/// No description provided for @notificationCategoryMessageTitle.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -2438,12 +2462,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Back up your twonly identity, as this is the only way to restore your account if you uninstall the app or lose your phone.'**
|
/// **'Back up your twonly identity, as this is the only way to restore your account if you uninstall the app or lose your phone.'**
|
||||||
String get onboardingBackupBody;
|
String get onboardingBackupBody;
|
||||||
|
|
||||||
/// No description provided for @onboardingVerificationBadgeTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Verification Badge'**
|
|
||||||
String get onboardingVerificationBadgeTitle;
|
|
||||||
|
|
||||||
/// No description provided for @onboardingUserDiscoveryShareFriends.
|
/// No description provided for @onboardingUserDiscoveryShareFriends.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3434,18 +3452,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Step-by-step setup so you can decide for yourself.'**
|
/// **'Step-by-step setup so you can decide for yourself.'**
|
||||||
String get onboardingProfileSelectionCustomizeDesc;
|
String get onboardingProfileSelectionCustomizeDesc;
|
||||||
|
|
||||||
/// No description provided for @onboardingProfileSelectionStrictTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Enhanced Protection'**
|
|
||||||
String get onboardingProfileSelectionStrictTitle;
|
|
||||||
|
|
||||||
/// No description provided for @onboardingProfileSelectionStrictDesc.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Maximum anti-phishing defense. Recommended for *journalists & public figures*.'**
|
|
||||||
String get onboardingProfileSelectionStrictDesc;
|
|
||||||
|
|
||||||
/// No description provided for @replyFlameRestored.
|
/// No description provided for @replyFlameRestored.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3458,24 +3464,6 @@ abstract class AppLocalizations {
|
||||||
/// **'Ask a friend'**
|
/// **'Ask a friend'**
|
||||||
String get replyAskAFriend;
|
String get replyAskAFriend;
|
||||||
|
|
||||||
/// No description provided for @unverifiedWarningDirectTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Identity not verified in person'**
|
|
||||||
String get unverifiedWarningDirectTitle;
|
|
||||||
|
|
||||||
/// No description provided for @unverifiedWarningGroupTitle.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Not all members are verified in person'**
|
|
||||||
String get unverifiedWarningGroupTitle;
|
|
||||||
|
|
||||||
/// No description provided for @unverifiedWarningBody.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'*Avoid sharing sensitive data*. Risk of *impersonation* without manual verification.'**
|
|
||||||
String get unverifiedWarningBody;
|
|
||||||
|
|
||||||
/// No description provided for @unverifiedWarningButton.
|
/// No description provided for @unverifiedWarningButton.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3563,12 +3551,8 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @importGalleryComplete.
|
/// No description provided for @importGalleryComplete.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Import complete: {imported} successfully imported, {duplicated} duplicated and {failed} failed.'**
|
/// **'Import complete: {imported} successfully imported and {failed} failed.'**
|
||||||
String importGalleryComplete(
|
String importGalleryComplete(Object imported, Object failed);
|
||||||
Object imported,
|
|
||||||
Object duplicated,
|
|
||||||
Object failed,
|
|
||||||
);
|
|
||||||
|
|
||||||
/// No description provided for @importGalleryGrantAccess.
|
/// No description provided for @importGalleryGrantAccess.
|
||||||
///
|
///
|
||||||
|
|
@ -3804,54 +3788,18 @@ abstract class AppLocalizations {
|
||||||
/// **'These friends either have not yet received their share or do not use twonly actively anymore, which could mean that they maybe cannot help.'**
|
/// **'These friends either have not yet received their share or do not use twonly actively anymore, which could mean that they maybe cannot help.'**
|
||||||
String get passwordlessRecoveryInactiveFriendsDesc;
|
String get passwordlessRecoveryInactiveFriendsDesc;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryNotEnoughFriends.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Not enough friends selected. You need at least 3.'**
|
|
||||||
String get passwordlessRecoveryNotEnoughFriends;
|
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryLoading.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Loading...'**
|
|
||||||
String get passwordlessRecoveryLoading;
|
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryEnableSuccess.
|
/// No description provided for @passwordlessRecoveryEnableSuccess.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Passwordless recovery successfully enabled!'**
|
/// **'Passwordless recovery successfully enabled!'**
|
||||||
String get passwordlessRecoveryEnableSuccess;
|
String get passwordlessRecoveryEnableSuccess;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryEnableFailed.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Failed to enable passwordless recovery.'**
|
|
||||||
String get passwordlessRecoveryEnableFailed;
|
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryNeedAtLeast3.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'You need at least 3 trusted friends.'**
|
|
||||||
String get passwordlessRecoveryNeedAtLeast3;
|
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryInvalidPin.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Invalid PIN'**
|
|
||||||
String get passwordlessRecoveryInvalidPin;
|
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryEnterPin.
|
/// No description provided for @passwordlessRecoveryEnterPin.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Please enter a PIN.'**
|
/// **'Please enter a PIN.'**
|
||||||
String get passwordlessRecoveryEnterPin;
|
String get passwordlessRecoveryEnterPin;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryPinMinLength.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'PIN must be at least 4 digits.'**
|
|
||||||
String get passwordlessRecoveryPinMinLength;
|
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryEnterEmail.
|
/// No description provided for @passwordlessRecoveryEnterEmail.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -3891,7 +3839,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @passwordlessRecoveryInfoHowItWorksDesc.
|
/// No description provided for @passwordlessRecoveryInfoHowItWorksDesc.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Because twonly operates without central user accounts or phone numbers to maximize privacy, we rely on a decentralized recovery mechanism. Using Shamir\'s Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined threshold of these friends must combine their shares.'**
|
/// **'twonly operates without phone numbers, which is why we rely on a decentralized recovery mechanism. Using Shamir\'s Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined number of these friends must combine their shares.'**
|
||||||
String get passwordlessRecoveryInfoHowItWorksDesc;
|
String get passwordlessRecoveryInfoHowItWorksDesc;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryInfoWhySecondFactor.
|
/// No description provided for @passwordlessRecoveryInfoWhySecondFactor.
|
||||||
|
|
@ -3903,7 +3851,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @passwordlessRecoveryInfoWhySecondFactorDesc.
|
/// No description provided for @passwordlessRecoveryInfoWhySecondFactorDesc.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'The second factor (Email or PIN) serves as a vital cryptographic safeguard against malicious collusion. If your trusted friends were to coordinate their shares behind your back, they still wouldn\'t be able to decrypt your identity without the second factor key.'**
|
/// **'The second factor (Email or PIN) provides important additional protection. Even if your friends were to secretly team up, they still couldn\'t access your account without this second factor.'**
|
||||||
String get passwordlessRecoveryInfoWhySecondFactorDesc;
|
String get passwordlessRecoveryInfoWhySecondFactorDesc;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryInfoGotIt.
|
/// No description provided for @passwordlessRecoveryInfoGotIt.
|
||||||
|
|
@ -3966,6 +3914,18 @@ abstract class AppLocalizations {
|
||||||
/// **'Select trusted friends'**
|
/// **'Select trusted friends'**
|
||||||
String get passwordlessRecoverySelectFriends;
|
String get passwordlessRecoverySelectFriends;
|
||||||
|
|
||||||
|
/// No description provided for @passwordlessRecoveryTrustedFriends.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Trusted Friends'**
|
||||||
|
String get passwordlessRecoveryTrustedFriends;
|
||||||
|
|
||||||
|
/// No description provided for @passwordlessRecoveryDoneBtn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Done ({count})'**
|
||||||
|
String passwordlessRecoveryDoneBtn(num count);
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryNoFriendsSelected.
|
/// No description provided for @passwordlessRecoveryNoFriendsSelected.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -4032,10 +3992,16 @@ abstract class AppLocalizations {
|
||||||
/// **'No recovery share stored for this contact.'**
|
/// **'No recovery share stored for this contact.'**
|
||||||
String get passwordlessRecoveryNoShareStored;
|
String get passwordlessRecoveryNoShareStored;
|
||||||
|
|
||||||
|
/// No description provided for @passwordlessRecoveryEmailSent.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recovery email sent!'**
|
||||||
|
String get passwordlessRecoveryEmailSent;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryShareSent.
|
/// No description provided for @passwordlessRecoveryShareSent.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Recovery share successfully sent!'**
|
/// **'Recovery share sent!'**
|
||||||
String get passwordlessRecoveryShareSent;
|
String get passwordlessRecoveryShareSent;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryNetworkError.
|
/// No description provided for @passwordlessRecoveryNetworkError.
|
||||||
|
|
@ -4083,19 +4049,19 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @passwordlessRecoveryCantHelpHim.
|
/// No description provided for @passwordlessRecoveryCantHelpHim.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'You can\'t help him'**
|
/// **'You can\'t help this person'**
|
||||||
String get passwordlessRecoveryCantHelpHim;
|
String get passwordlessRecoveryCantHelpHim;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryDoesAskedYou.
|
/// No description provided for @passwordlessRecoveryDoesAskedYou.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Does {username} has asked you?'**
|
/// **'Has {username} asked you?'**
|
||||||
String passwordlessRecoveryDoesAskedYou(Object username);
|
String passwordlessRecoveryDoesAskedYou(Object username);
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryVerifySourceDesc.
|
/// No description provided for @passwordlessRecoveryVerifySourceDesc.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Please ensure that you actualy received the link/qr code from your friend! If you are unsure, please verify again and then click again on the link or come back.'**
|
/// **'Please make sure this request really came from this person. If in doubt, ask them via another channel!'**
|
||||||
String get passwordlessRecoveryVerifySourceDesc;
|
String get passwordlessRecoveryVerifySourceDesc;
|
||||||
|
|
||||||
/// No description provided for @passwordlessRecoveryNo.
|
/// No description provided for @passwordlessRecoveryNo.
|
||||||
|
|
@ -4116,16 +4082,10 @@ abstract class AppLocalizations {
|
||||||
/// **'Yes ({seconds}s)'**
|
/// **'Yes ({seconds}s)'**
|
||||||
String passwordlessRecoveryYesWithTimer(Object seconds);
|
String passwordlessRecoveryYesWithTimer(Object seconds);
|
||||||
|
|
||||||
/// No description provided for @recoverPasswordlessBeingRecoveredLabel.
|
|
||||||
///
|
|
||||||
/// In en, this message translates to:
|
|
||||||
/// **'Account being recovered'**
|
|
||||||
String get recoverPasswordlessBeingRecoveredLabel;
|
|
||||||
|
|
||||||
/// No description provided for @recoverPasswordlessSharesReceived.
|
/// No description provided for @recoverPasswordlessSharesReceived.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'{received} of {threshold} friends have shared'**
|
/// **'{received} out of {threshold} friends have helped'**
|
||||||
String recoverPasswordlessSharesReceived(Object received, Object threshold);
|
String recoverPasswordlessSharesReceived(Object received, Object threshold);
|
||||||
|
|
||||||
/// No description provided for @recoverPasswordlessRecoverNowBtn.
|
/// No description provided for @recoverPasswordlessRecoverNowBtn.
|
||||||
|
|
@ -4133,6 +4093,186 @@ abstract class AppLocalizations {
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Recover now'**
|
/// **'Recover now'**
|
||||||
String get recoverPasswordlessRecoverNowBtn;
|
String get recoverPasswordlessRecoverNowBtn;
|
||||||
|
|
||||||
|
/// No description provided for @missingRecoveryContactsCardTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Recovery Contacts'**
|
||||||
|
String get missingRecoveryContactsCardTitle;
|
||||||
|
|
||||||
|
/// No description provided for @missingRecoveryContactsCardDesc.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select Recovery Contacts to help you recover your account without requiring a password.'**
|
||||||
|
String get missingRecoveryContactsCardDesc;
|
||||||
|
|
||||||
|
/// No description provided for @missingRecoveryContactsCardAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Select Contacts'**
|
||||||
|
String get missingRecoveryContactsCardAction;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesBackupLimitReached.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cloud backup limit reached! Please upgrade your plan or free up space.'**
|
||||||
|
String get memoriesBackupLimitReached;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageLocal.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Local storage'**
|
||||||
|
String get settingsStorageLocal;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageNoCloudBackupTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No Cloud Backup'**
|
||||||
|
String get settingsStorageNoCloudBackupTitle;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageNoCloudBackupCard.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Back up your memories to the cloud to free up local space and ensure you never lose your pictures. All end-to-end encrypted.'**
|
||||||
|
String get settingsStorageNoCloudBackupCard;
|
||||||
|
|
||||||
|
/// No description provided for @galleryActionSave.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Save'**
|
||||||
|
String get galleryActionSave;
|
||||||
|
|
||||||
|
/// No description provided for @galleryActionExport.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Export'**
|
||||||
|
String get galleryActionExport;
|
||||||
|
|
||||||
|
/// No description provided for @galleryActionFavorite.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Favorite'**
|
||||||
|
String get galleryActionFavorite;
|
||||||
|
|
||||||
|
/// No description provided for @galleryActionUnfavorite.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Unfavorite'**
|
||||||
|
String get galleryActionUnfavorite;
|
||||||
|
|
||||||
|
/// No description provided for @galleryActionDelete.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Delete'**
|
||||||
|
String get galleryActionDelete;
|
||||||
|
|
||||||
|
/// No description provided for @galleryActionShare.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Share'**
|
||||||
|
String get galleryActionShare;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageHidePromo.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Hide'**
|
||||||
|
String get settingsStorageHidePromo;
|
||||||
|
|
||||||
|
/// No description provided for @memoriesBackupTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Memories Backup'**
|
||||||
|
String get memoriesBackupTitle;
|
||||||
|
|
||||||
|
/// No description provided for @settingsHelpNews.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'News'**
|
||||||
|
String get settingsHelpNews;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageContents.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Storage contents'**
|
||||||
|
String get settingsStorageContents;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageSortStorage.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Occupied storage'**
|
||||||
|
String get settingsStorageSortStorage;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageSortNewest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Newest'**
|
||||||
|
String get settingsStorageSortNewest;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageSortOldest.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Oldest'**
|
||||||
|
String get settingsStorageSortOldest;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageNoContents.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'No storage contents found.'**
|
||||||
|
String get settingsStorageNoContents;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageCloudOnly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Cloud only'**
|
||||||
|
String get settingsStorageCloudOnly;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageLocalAndCloud.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Local and cloud'**
|
||||||
|
String get settingsStorageLocalAndCloud;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageLocalOnly.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Local only'**
|
||||||
|
String get settingsStorageLocalOnly;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageDisableBackupTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disable Backup?'**
|
||||||
|
String get settingsStorageDisableBackupTitle;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageDisableBackupBody.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'This will delete all backed up memories from the cloud. There {count, plural, =1 {is *1 image*} other {are *{count} images*}} only stored in the cloud that will be *permanently lost*. Are you sure you want to proceed?'**
|
||||||
|
String settingsStorageDisableBackupBody(num count);
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageDisableBackupBtn.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disable'**
|
||||||
|
String get settingsStorageDisableBackupBtn;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageDisableBackupAction.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Disable Cloud Backup'**
|
||||||
|
String get settingsStorageDisableBackupAction;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageSyncNow.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Sync now'**
|
||||||
|
String get settingsStorageSyncNow;
|
||||||
|
|
||||||
|
/// No description provided for @settingsStorageSyncUpToDate.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'All memories are up to date.'**
|
||||||
|
String get settingsStorageSyncUpToDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|
|
||||||
|
|
@ -386,7 +386,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get contactUsSuccess => 'Feedback erfolgreich übermittelt!';
|
String get contactUsSuccess => 'Feedback erfolgreich übermittelt!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactUsShortcut => 'Feedback-Symbol ausblenden';
|
String get hideNewsIcon => 'Neuigkeiten-Symbol ausblenden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsHelpTerms => 'Nutzungsbedingungen';
|
String get settingsHelpTerms => 'Nutzungsbedingungen';
|
||||||
|
|
@ -459,10 +459,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get verificationTypeLink => 'Per Link verifiziert.';
|
String get verificationTypeLink => 'Per Link verifiziert.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get verificationTypeContactSharedByVerified =>
|
|
||||||
'Von einem verifizierten Kontakt geteilt bekommen.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get verificationTypeMigratedFromOldVersion =>
|
String get verificationTypeMigratedFromOldVersion =>
|
||||||
'Von alter Version migriert';
|
'Von alter Version migriert';
|
||||||
|
|
@ -656,10 +652,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get proFeature2 => '✓ 1 zusätzlicher Plus Benutzer';
|
String get proFeature2 => '✓ 1 zusätzlicher Plus Benutzer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get proFeature3 => '✓ Flammen wiederherstellen';
|
String get proFeature3 => '✓ 25GB Memories Speicher';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get proFeature4 => '✓ twonly unterstützen';
|
String get proFeature4 => '✓ Flammen wiederherstellen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get familyFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads';
|
String get familyFeature1 => '✓ Unbegrenzte Medien-Datei-Uploads';
|
||||||
|
|
@ -668,10 +664,10 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer';
|
String get familyFeature2 => '✓ 4 zusätzliche Plus Benutzer';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get familyFeature3 => '✓ Flammen wiederherstellen';
|
String get familyFeature3 => '✓ 50GB Memories Speicher';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get familyFeature4 => '✓ twonly unterstützen';
|
String get familyFeature4 => '✓ Flammen wiederherstellen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get freeFeature1 => '✓ 10 Medien-Datei-Uploads pro Tag';
|
String get freeFeature1 => '✓ 10 Medien-Datei-Uploads pro Tag';
|
||||||
|
|
@ -762,6 +758,41 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteMemoriesLocalOnly => 'Nur lokal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteMemoriesCompletely => 'Komplett';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String memoriesSelectedCount(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Elemente',
|
||||||
|
one: '1 Element',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuSelectAll => 'Alles auswählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuDeselectAll => 'Alle abwählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuExport => 'In Gallery exportieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuFavorite => 'Favorisieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuDelete => 'Löschen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuDeleteLocal => 'Lokal löschen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsBackup => 'Backup';
|
String get settingsBackup => 'Backup';
|
||||||
|
|
||||||
|
|
@ -883,9 +914,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get retransmissionRequested => 'Wird erneut versucht.';
|
String get retransmissionRequested => 'Wird erneut versucht.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get openChangeLog => 'Changelog automatisch öffnen';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String reportUserTitle(Object username) {
|
String reportUserTitle(Object username) {
|
||||||
return 'Melde $username';
|
return 'Melde $username';
|
||||||
|
|
@ -1146,10 +1174,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get notificationContactRequest => 'möchte sich mit dir vernetzen.';
|
String get notificationContactRequest => 'möchte sich mit dir vernetzen.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get notificationContactRequestUnknownUser =>
|
|
||||||
'hast eine neue Kontaktanfrage erhalten.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get notificationAcceptRequest => 'ist jetzt mit dir vernetzt.';
|
String get notificationAcceptRequest => 'ist jetzt mit dir vernetzt.';
|
||||||
|
|
||||||
|
|
@ -1187,12 +1211,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return 'hat dir$inGroup geantwortet.';
|
return 'hat dir$inGroup geantwortet.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get notificationTitleUnknown => 'Du hast eine neue Nachricht.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get notificationBodyUnknown => 'Öffne twonly um mehr zu erfahren.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get notificationCategoryMessageTitle => 'Nachrichten';
|
String get notificationCategoryMessageTitle => 'Nachrichten';
|
||||||
|
|
||||||
|
|
@ -1353,9 +1371,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get onboardingBackupBody =>
|
String get onboardingBackupBody =>
|
||||||
'Sichere deine twonly-Identität, da dies die einzige Möglichkeit ist, dein Konto wiederherzustellen, wenn du die App deinstallierst oder dein Handy verlierst.';
|
'Sichere deine twonly-Identität, da dies die einzige Möglichkeit ist, dein Konto wiederherzustellen, wenn du die App deinstallierst oder dein Handy verlierst.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get onboardingVerificationBadgeTitle => 'Verifizierungs-Haken';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onboardingUserDiscoveryShareFriends => 'Freunde teilen';
|
String get onboardingUserDiscoveryShareFriends => 'Freunde teilen';
|
||||||
|
|
||||||
|
|
@ -1972,31 +1987,12 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get onboardingProfileSelectionCustomizeDesc =>
|
String get onboardingProfileSelectionCustomizeDesc =>
|
||||||
'Schritt-für-Schritt-Einrichtung, damit du selbst entscheiden kannst.';
|
'Schritt-für-Schritt-Einrichtung, damit du selbst entscheiden kannst.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get onboardingProfileSelectionStrictTitle => 'Erhöhter Schutz';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get onboardingProfileSelectionStrictDesc =>
|
|
||||||
'Maximaler Schutz vor Phishing. Empfohlen für *Journalisten & Personen des öffentlichen Lebens*.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get replyFlameRestored => 'Flammen wiederhergestellt';
|
String get replyFlameRestored => 'Flammen wiederhergestellt';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get replyAskAFriend => 'Einen Freund fragen';
|
String get replyAskAFriend => 'Einen Freund fragen';
|
||||||
|
|
||||||
@override
|
|
||||||
String get unverifiedWarningDirectTitle =>
|
|
||||||
'Identität nicht persönlich verifiziert';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get unverifiedWarningGroupTitle =>
|
|
||||||
'Nicht alle Mitglieder sind persönlich verifiziert';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get unverifiedWarningBody =>
|
|
||||||
'*Teile keine geheimen Daten*. Jemand könnte sich *als dein Freund ausgeben*.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unverifiedWarningButton => 'Jetzt verifizieren';
|
String get unverifiedWarningButton => 'Jetzt verifizieren';
|
||||||
|
|
||||||
|
|
@ -2048,12 +2044,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get importGalleryStarting => 'Import wird gestartet...';
|
String get importGalleryStarting => 'Import wird gestartet...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String importGalleryComplete(
|
String importGalleryComplete(Object imported, Object failed) {
|
||||||
Object imported,
|
return 'Import abgeschlossen: $imported erfolgreich importiert und $failed fehlgeschlagen.';
|
||||||
Object duplicated,
|
|
||||||
Object failed,
|
|
||||||
) {
|
|
||||||
return 'Import abgeschlossen: $imported erfolgreich importiert, $duplicated Duplikate und $failed fehlgeschlagen.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -2190,35 +2182,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get passwordlessRecoveryInactiveFriendsDesc =>
|
String get passwordlessRecoveryInactiveFriendsDesc =>
|
||||||
'Diese Freunde haben entweder ihren Teil noch nicht erhalten oder nutzen twonly nicht mehr aktiv, was bedeutet, dass sie möglicherweise nicht helfen können.';
|
'Diese Freunde haben entweder ihren Teil noch nicht erhalten oder nutzen twonly nicht mehr aktiv, was bedeutet, dass sie möglicherweise nicht helfen können.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryNotEnoughFriends =>
|
|
||||||
'Nicht genügend Freunde ausgewählt. Du brauchst mindestens 3.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryLoading => 'Wird geladen...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryEnableSuccess =>
|
String get passwordlessRecoveryEnableSuccess =>
|
||||||
'Passwortloses Backup erfolgreich aktiviert!';
|
'Passwortloses Backup erfolgreich aktiviert!';
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryEnableFailed =>
|
|
||||||
'Fehler beim Aktivieren des passwortlosen Backups.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryNeedAtLeast3 =>
|
|
||||||
'Du brauchst mindestens 3 Freunde.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryInvalidPin => 'Ungültige PIN';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryEnterPin => 'Bitte gib eine PIN ein.';
|
String get passwordlessRecoveryEnterPin => 'Bitte gib eine PIN ein.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryPinMinLength =>
|
|
||||||
'Die PIN muss mindestens 4 Ziffern lang sein.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryEnterEmail =>
|
String get passwordlessRecoveryEnterEmail =>
|
||||||
'Bitte gib eine E-Mail-Adresse ein.';
|
'Bitte gib eine E-Mail-Adresse ein.';
|
||||||
|
|
@ -2242,7 +2212,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoHowItWorksDesc =>
|
String get passwordlessRecoveryInfoHowItWorksDesc =>
|
||||||
'Da twonly ohne zentrale Benutzerkonten oder Telefonnummern funktioniert, um deine Privatsphäre zu maximieren, setzen wir auf einen dezentralen Wiederherstellungsmechanismus. Mit Shamir\'s Secret Sharing wird deine kryptografische Identität in unabhängige Teile aufgeteilt und an deine Freunde verteilt. Um den Zugriff wiederherzustellen, muss ein vorher festgelegter Schwellenwert dieser Freunde ihre Teile kombinieren.';
|
'twonly funktioniert ohne Telefonnummern, weshalb wir auf einen dezentralen Wiederherstellungsmechanismus setzen. Mit Shamir\'s Secret Sharing wird deine kryptografische Identität in unabhängige Teile aufgeteilt und an deine Freunde verteilt. Um den Zugriff wiederherzustellen, muss eine vorher festgelegte Anzahl dieser Freunde ihre Teile kombinieren.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoWhySecondFactor =>
|
String get passwordlessRecoveryInfoWhySecondFactor =>
|
||||||
|
|
@ -2250,7 +2220,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
|
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
|
||||||
'Der zweite Faktor (E-Mail oder PIN) dient als wichtiger kryptografischer Schutz vor bösartiger Absprache. Wenn sich deine Freunde hinter deinem Rücken absprechen würden, könnten sie deine Identität ohne den Schlüssel für den zweiten Faktor trotzdem nicht entschlüsseln.';
|
'Der zweite Faktor (E-Mail oder PIN) ist ein wichtiger zusätzlicher Schutz. Selbst wenn sich deine Freunde heimlich zusammentun, können sie ohne diesen zweiten Faktor nicht auf dein Konto zugreifen.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoGotIt => 'Verstanden';
|
String get passwordlessRecoveryInfoGotIt => 'Verstanden';
|
||||||
|
|
@ -2290,6 +2260,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoverySelectFriends => 'Freunde auswählen';
|
String get passwordlessRecoverySelectFriends => 'Freunde auswählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordlessRecoveryTrustedFriends => 'Vertrauenswürdige Freunde';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String passwordlessRecoveryDoneBtn(num count) {
|
||||||
|
return 'Fertig ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNoFriendsSelected =>
|
String get passwordlessRecoveryNoFriendsSelected =>
|
||||||
'Noch keine Freunde ausgewählt';
|
'Noch keine Freunde ausgewählt';
|
||||||
|
|
@ -2332,9 +2310,13 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get passwordlessRecoveryNoShareStored =>
|
String get passwordlessRecoveryNoShareStored =>
|
||||||
'Kein Wiederherstellungs-Teil für diesen Kontakt gespeichert.';
|
'Kein Wiederherstellungs-Teil für diesen Kontakt gespeichert.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordlessRecoveryEmailSent =>
|
||||||
|
'Wiederherstellungs-E-Mail gesendet!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryShareSent =>
|
String get passwordlessRecoveryShareSent =>
|
||||||
'Wiederherstellungs-Teil erfolgreich gesendet!';
|
'Wiederherstellungs-Teil gesendet!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNetworkError =>
|
String get passwordlessRecoveryNetworkError =>
|
||||||
|
|
@ -2361,7 +2343,8 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
String get passwordlessRecoveryNoContactsFound => 'Keine Kontakte gefunden';
|
String get passwordlessRecoveryNoContactsFound => 'Keine Kontakte gefunden';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryCantHelpHim => 'Du kannst ihm nicht helfen';
|
String get passwordlessRecoveryCantHelpHim =>
|
||||||
|
'Du kannst dieser Person nicht helfen';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String passwordlessRecoveryDoesAskedYou(Object username) {
|
String passwordlessRecoveryDoesAskedYou(Object username) {
|
||||||
|
|
@ -2370,7 +2353,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryVerifySourceDesc =>
|
String get passwordlessRecoveryVerifySourceDesc =>
|
||||||
'Bitte stelle sicher, dass du den Link/QR-Code tatsächlich von deinem Freund erhalten hast! Wenn du dir unsicher bist, verifiziere dies bitte erneut und klicke dann noch einmal auf den Link oder kehre zurück.';
|
'Bitte stelle sicher, dass diese Anfrage wirklich von dieser Person stammt. Im Zweifel frage kurz über einen anderen Kanal nach!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNo => 'Nein';
|
String get passwordlessRecoveryNo => 'Nein';
|
||||||
|
|
@ -2383,15 +2366,113 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return 'Ja (${seconds}s)';
|
return 'Ja (${seconds}s)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get recoverPasswordlessBeingRecoveredLabel =>
|
|
||||||
'Konto wird wiederhergestellt';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recoverPasswordlessSharesReceived(Object received, Object threshold) {
|
String recoverPasswordlessSharesReceived(Object received, Object threshold) {
|
||||||
return '$received von $threshold Freunden haben geteilt';
|
return '$received von $threshold Freunden haben geholfen';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recoverPasswordlessRecoverNowBtn => 'Jetzt wiederherstellen';
|
String get recoverPasswordlessRecoverNowBtn => 'Jetzt wiederherstellen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardTitle => 'Vertrauenswürdige Kontakte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardDesc =>
|
||||||
|
'Wähle Kontakte für die Kontowiederherstellung aus, die dir helfen, dein Konto ohne Passwort wiederherzustellen.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardAction => 'Kontakte auswählen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesBackupLimitReached =>
|
||||||
|
'Cloud-Backup-Limit erreicht! Bitte aktualisiere dein Abonnement oder gib Speicherplatz frei.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageLocal => 'Lokaler Speicherplatz';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageNoCloudBackupTitle => 'Kein Cloud-Backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageNoCloudBackupCard =>
|
||||||
|
'Sichere deine Erinnerungen in der Cloud, um lokalen Speicherplatz freizugeben und deine Bilder nicht zu verlieren. Alles Ende-zu-Ende verschlüsselt.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionSave => 'Sichern';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionExport => 'Exportieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionFavorite => 'Favorit';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionUnfavorite => 'Favorit';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionDelete => 'Löschen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionShare => 'Teilen';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageHidePromo => 'Ausblenden';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesBackupTitle => 'Memories Backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsHelpNews => 'Neuigkeiten';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageContents => 'Speicherinhalte';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSortStorage => 'Belegter Speicher';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSortNewest => 'Neueste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSortOldest => 'Älteste';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageNoContents => 'Keine Speicherinhalte gefunden.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageCloudOnly => 'Nur Cloud';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageLocalAndCloud => 'Lokal & Cloud';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageLocalOnly => 'Nur lokal';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageDisableBackupTitle => 'Backup deaktivieren?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settingsStorageDisableBackupBody(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '*$count Bilder*, die',
|
||||||
|
one: '*1 Bild*, das',
|
||||||
|
);
|
||||||
|
return 'Dadurch werden alle gesicherten Erinnerungen aus der Cloud gelöscht. Es gibt $_temp0 nur in der Cloud und nicht lokal gespeichert sind. Diese gehen *dauerhaft verloren*. Fortfahren?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageDisableBackupBtn => 'Deaktivieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageDisableBackupAction => 'Cloud-Backup deaktivieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSyncNow => 'Jetzt synchronisieren';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSyncUpToDate =>
|
||||||
|
'Alle Memories sind auf dem neuesten Stand.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -382,7 +382,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get contactUsSuccess => 'Feedback submitted successfully!';
|
String get contactUsSuccess => 'Feedback submitted successfully!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get contactUsShortcut => 'Hide Feedback Icon';
|
String get hideNewsIcon => 'Hide News Icon';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsHelpTerms => 'Terms of Service';
|
String get settingsHelpTerms => 'Terms of Service';
|
||||||
|
|
@ -455,10 +455,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get verificationTypeLink => 'Verified via link.';
|
String get verificationTypeLink => 'Verified via link.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get verificationTypeContactSharedByVerified =>
|
|
||||||
'Contact received from a verified contact.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get verificationTypeMigratedFromOldVersion =>
|
String get verificationTypeMigratedFromOldVersion =>
|
||||||
'Migrated from old version.';
|
'Migrated from old version.';
|
||||||
|
|
@ -651,10 +647,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get proFeature2 => '✓ 1 additional Plus user';
|
String get proFeature2 => '✓ 1 additional Plus user';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get proFeature3 => '✓ Restore flames';
|
String get proFeature3 => '✓ 25GB Memories storage';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get proFeature4 => '✓ Support twonly';
|
String get proFeature4 => '✓ Restore flames';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get familyFeature1 => '✓ Unlimited media file uploads';
|
String get familyFeature1 => '✓ Unlimited media file uploads';
|
||||||
|
|
@ -663,7 +659,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get familyFeature2 => '✓ 4 additional Plus user';
|
String get familyFeature2 => '✓ 4 additional Plus user';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get familyFeature3 => '✓ Restore flames';
|
String get familyFeature3 => '✓ 50GB Memories storage';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get familyFeature4 => '✓ Support twonly';
|
String get familyFeature4 => '✓ Support twonly';
|
||||||
|
|
@ -757,6 +753,41 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return '$_temp0';
|
return '$_temp0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteMemoriesLocalOnly => 'Local Only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get deleteMemoriesCompletely => 'Completely';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String memoriesSelectedCount(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: '$count Elements',
|
||||||
|
one: '1 Element',
|
||||||
|
);
|
||||||
|
return '$_temp0';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuSelectAll => 'Select all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuDeselectAll => 'Deselect all';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuExport => 'Export to gallery';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuFavorite => 'Favorite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuDelete => 'Delete completely';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesMenuDeleteLocal => 'Delete locally';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get settingsBackup => 'Backup';
|
String get settingsBackup => 'Backup';
|
||||||
|
|
||||||
|
|
@ -878,9 +909,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get retransmissionRequested => 'Retransmission requested';
|
String get retransmissionRequested => 'Retransmission requested';
|
||||||
|
|
||||||
@override
|
|
||||||
String get openChangeLog => 'Open changelog automatically';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String reportUserTitle(Object username) {
|
String reportUserTitle(Object username) {
|
||||||
return 'Report $username';
|
return 'Report $username';
|
||||||
|
|
@ -1140,10 +1168,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get notificationContactRequest => 'wants to connect with you.';
|
String get notificationContactRequest => 'wants to connect with you.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get notificationContactRequestUnknownUser =>
|
|
||||||
'have received a new contact request.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get notificationAcceptRequest => 'is now connected with you.';
|
String get notificationAcceptRequest => 'is now connected with you.';
|
||||||
|
|
||||||
|
|
@ -1181,12 +1205,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'has responded$inGroup.';
|
return 'has responded$inGroup.';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get notificationTitleUnknown => 'You have a new message.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get notificationBodyUnknown => 'Open twonly to learn more.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get notificationCategoryMessageTitle => 'Messages';
|
String get notificationCategoryMessageTitle => 'Messages';
|
||||||
|
|
||||||
|
|
@ -1345,9 +1363,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get onboardingBackupBody =>
|
String get onboardingBackupBody =>
|
||||||
'Back up your twonly identity, as this is the only way to restore your account if you uninstall the app or lose your phone.';
|
'Back up your twonly identity, as this is the only way to restore your account if you uninstall the app or lose your phone.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get onboardingVerificationBadgeTitle => 'Verification Badge';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onboardingUserDiscoveryShareFriends => 'Share your friends';
|
String get onboardingUserDiscoveryShareFriends => 'Share your friends';
|
||||||
|
|
||||||
|
|
@ -1958,30 +1973,12 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get onboardingProfileSelectionCustomizeDesc =>
|
String get onboardingProfileSelectionCustomizeDesc =>
|
||||||
'Step-by-step setup so you can decide for yourself.';
|
'Step-by-step setup so you can decide for yourself.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get onboardingProfileSelectionStrictTitle => 'Enhanced Protection';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get onboardingProfileSelectionStrictDesc =>
|
|
||||||
'Maximum anti-phishing defense. Recommended for *journalists & public figures*.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get replyFlameRestored => 'Flames restored';
|
String get replyFlameRestored => 'Flames restored';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get replyAskAFriend => 'Ask a friend';
|
String get replyAskAFriend => 'Ask a friend';
|
||||||
|
|
||||||
@override
|
|
||||||
String get unverifiedWarningDirectTitle => 'Identity not verified in person';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get unverifiedWarningGroupTitle =>
|
|
||||||
'Not all members are verified in person';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get unverifiedWarningBody =>
|
|
||||||
'*Avoid sharing sensitive data*. Risk of *impersonation* without manual verification.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get unverifiedWarningButton => 'Verify now';
|
String get unverifiedWarningButton => 'Verify now';
|
||||||
|
|
||||||
|
|
@ -2033,12 +2030,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get importGalleryStarting => 'Starting import...';
|
String get importGalleryStarting => 'Starting import...';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String importGalleryComplete(
|
String importGalleryComplete(Object imported, Object failed) {
|
||||||
Object imported,
|
return 'Import complete: $imported successfully imported and $failed failed.';
|
||||||
Object duplicated,
|
|
||||||
Object failed,
|
|
||||||
) {
|
|
||||||
return 'Import complete: $imported successfully imported, $duplicated duplicated and $failed failed.';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -2175,35 +2168,13 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get passwordlessRecoveryInactiveFriendsDesc =>
|
String get passwordlessRecoveryInactiveFriendsDesc =>
|
||||||
'These friends either have not yet received their share or do not use twonly actively anymore, which could mean that they maybe cannot help.';
|
'These friends either have not yet received their share or do not use twonly actively anymore, which could mean that they maybe cannot help.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryNotEnoughFriends =>
|
|
||||||
'Not enough friends selected. You need at least 3.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryLoading => 'Loading...';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryEnableSuccess =>
|
String get passwordlessRecoveryEnableSuccess =>
|
||||||
'Passwordless recovery successfully enabled!';
|
'Passwordless recovery successfully enabled!';
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryEnableFailed =>
|
|
||||||
'Failed to enable passwordless recovery.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryNeedAtLeast3 =>
|
|
||||||
'You need at least 3 trusted friends.';
|
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryInvalidPin => 'Invalid PIN';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryEnterPin => 'Please enter a PIN.';
|
String get passwordlessRecoveryEnterPin => 'Please enter a PIN.';
|
||||||
|
|
||||||
@override
|
|
||||||
String get passwordlessRecoveryPinMinLength =>
|
|
||||||
'PIN must be at least 4 digits.';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryEnterEmail => 'Please enter an email address.';
|
String get passwordlessRecoveryEnterEmail => 'Please enter an email address.';
|
||||||
|
|
||||||
|
|
@ -2226,14 +2197,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoHowItWorksDesc =>
|
String get passwordlessRecoveryInfoHowItWorksDesc =>
|
||||||
'Because twonly operates without central user accounts or phone numbers to maximize privacy, we rely on a decentralized recovery mechanism. Using Shamir\'s Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined threshold of these friends must combine their shares.';
|
'twonly operates without phone numbers, which is why we rely on a decentralized recovery mechanism. Using Shamir\'s Secret Sharing, your cryptographic identity is split into independent shares and distributed among your trusted friends. To restore access, a predefined number of these friends must combine their shares.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoWhySecondFactor => 'Why a Second Factor?';
|
String get passwordlessRecoveryInfoWhySecondFactor => 'Why a Second Factor?';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
|
String get passwordlessRecoveryInfoWhySecondFactorDesc =>
|
||||||
'The second factor (Email or PIN) serves as a vital cryptographic safeguard against malicious collusion. If your trusted friends were to coordinate their shares behind your back, they still wouldn\'t be able to decrypt your identity without the second factor key.';
|
'The second factor (Email or PIN) provides important additional protection. Even if your friends were to secretly team up, they still couldn\'t access your account without this second factor.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryInfoGotIt => 'Got it';
|
String get passwordlessRecoveryInfoGotIt => 'Got it';
|
||||||
|
|
@ -2273,6 +2244,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoverySelectFriends => 'Select trusted friends';
|
String get passwordlessRecoverySelectFriends => 'Select trusted friends';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get passwordlessRecoveryTrustedFriends => 'Trusted Friends';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String passwordlessRecoveryDoneBtn(num count) {
|
||||||
|
return 'Done ($count)';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNoFriendsSelected =>
|
String get passwordlessRecoveryNoFriendsSelected =>
|
||||||
'No trusted friends selected yet';
|
'No trusted friends selected yet';
|
||||||
|
|
@ -2314,8 +2293,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
'No recovery share stored for this contact.';
|
'No recovery share stored for this contact.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryShareSent =>
|
String get passwordlessRecoveryEmailSent => 'Recovery email sent!';
|
||||||
'Recovery share successfully sent!';
|
|
||||||
|
@override
|
||||||
|
String get passwordlessRecoveryShareSent => 'Recovery share sent!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNetworkError =>
|
String get passwordlessRecoveryNetworkError =>
|
||||||
|
|
@ -2342,16 +2323,16 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
String get passwordlessRecoveryNoContactsFound => 'No contacts found';
|
String get passwordlessRecoveryNoContactsFound => 'No contacts found';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryCantHelpHim => 'You can\'t help him';
|
String get passwordlessRecoveryCantHelpHim => 'You can\'t help this person';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String passwordlessRecoveryDoesAskedYou(Object username) {
|
String passwordlessRecoveryDoesAskedYou(Object username) {
|
||||||
return 'Does $username has asked you?';
|
return 'Has $username asked you?';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryVerifySourceDesc =>
|
String get passwordlessRecoveryVerifySourceDesc =>
|
||||||
'Please ensure that you actualy received the link/qr code from your friend! If you are unsure, please verify again and then click again on the link or come back.';
|
'Please make sure this request really came from this person. If in doubt, ask them via another channel!';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get passwordlessRecoveryNo => 'No';
|
String get passwordlessRecoveryNo => 'No';
|
||||||
|
|
@ -2364,15 +2345,112 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Yes (${seconds}s)';
|
return 'Yes (${seconds}s)';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
String get recoverPasswordlessBeingRecoveredLabel =>
|
|
||||||
'Account being recovered';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String recoverPasswordlessSharesReceived(Object received, Object threshold) {
|
String recoverPasswordlessSharesReceived(Object received, Object threshold) {
|
||||||
return '$received of $threshold friends have shared';
|
return '$received out of $threshold friends have helped';
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get recoverPasswordlessRecoverNowBtn => 'Recover now';
|
String get recoverPasswordlessRecoverNowBtn => 'Recover now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardTitle => 'Recovery Contacts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardDesc =>
|
||||||
|
'Select Recovery Contacts to help you recover your account without requiring a password.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get missingRecoveryContactsCardAction => 'Select Contacts';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesBackupLimitReached =>
|
||||||
|
'Cloud backup limit reached! Please upgrade your plan or free up space.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageLocal => 'Local storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageNoCloudBackupTitle => 'No Cloud Backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageNoCloudBackupCard =>
|
||||||
|
'Back up your memories to the cloud to free up local space and ensure you never lose your pictures. All end-to-end encrypted.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionSave => 'Save';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionExport => 'Export';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionFavorite => 'Favorite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionUnfavorite => 'Unfavorite';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionDelete => 'Delete';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get galleryActionShare => 'Share';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageHidePromo => 'Hide';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get memoriesBackupTitle => 'Memories Backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsHelpNews => 'News';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageContents => 'Storage contents';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSortStorage => 'Occupied storage';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSortNewest => 'Newest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSortOldest => 'Oldest';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageNoContents => 'No storage contents found.';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageCloudOnly => 'Cloud only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageLocalAndCloud => 'Local and cloud';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageLocalOnly => 'Local only';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageDisableBackupTitle => 'Disable Backup?';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String settingsStorageDisableBackupBody(num count) {
|
||||||
|
String _temp0 = intl.Intl.pluralLogic(
|
||||||
|
count,
|
||||||
|
locale: localeName,
|
||||||
|
other: 'are *$count images*',
|
||||||
|
one: 'is *1 image*',
|
||||||
|
);
|
||||||
|
return 'This will delete all backed up memories from the cloud. There $_temp0 only stored in the cloud that will be *permanently lost*. Are you sure you want to proceed?';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageDisableBackupBtn => 'Disable';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageDisableBackupAction => 'Disable Cloud Backup';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSyncNow => 'Sync now';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get settingsStorageSyncUpToDate => 'All memories are up to date.';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit b7d703c82a5ac0b6776847cb4eb6d637620f6585
|
Subproject commit 34c835db2de08af962b39fb79ec11341b8847926
|
||||||
|
|
@ -14,6 +14,7 @@ class ReceivedRecoveryShare {
|
||||||
required this.myAvatarSvg,
|
required this.myAvatarSvg,
|
||||||
required this.threshold,
|
required this.threshold,
|
||||||
required this.sharedSecretDataBytes,
|
required this.sharedSecretDataBytes,
|
||||||
|
this.trustedFriendAvatarSvg,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ReceivedRecoveryShare.fromJson(Map<String, dynamic> json) =>
|
factory ReceivedRecoveryShare.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|
@ -22,6 +23,7 @@ class ReceivedRecoveryShare {
|
||||||
final int messageId;
|
final int messageId;
|
||||||
|
|
||||||
final String trustedFriendDisplayName;
|
final String trustedFriendDisplayName;
|
||||||
|
final List<int>? trustedFriendAvatarSvg;
|
||||||
|
|
||||||
final String myDisplayName;
|
final String myDisplayName;
|
||||||
final int myUserId;
|
final int myUserId;
|
||||||
|
|
@ -58,7 +60,6 @@ class OnboardingState {
|
||||||
List<int>? encryptionKey;
|
List<int>? encryptionKey;
|
||||||
bool emailRecoveryRequested;
|
bool emailRecoveryRequested;
|
||||||
|
|
||||||
|
|
||||||
List<ReceivedRecoveryShare> receivedShares = [];
|
List<ReceivedRecoveryShare> receivedShares = [];
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => _$OnboardingStateToJson(this);
|
Map<String, dynamic> toJson() => _$OnboardingStateToJson(this);
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,9 @@ ReceivedRecoveryShare _$ReceivedRecoveryShareFromJson(
|
||||||
sharedSecretDataBytes: (json['sharedSecretDataBytes'] as List<dynamic>)
|
sharedSecretDataBytes: (json['sharedSecretDataBytes'] as List<dynamic>)
|
||||||
.map((e) => (e as num).toInt())
|
.map((e) => (e as num).toInt())
|
||||||
.toList(),
|
.toList(),
|
||||||
|
trustedFriendAvatarSvg: (json['trustedFriendAvatarSvg'] as List<dynamic>?)
|
||||||
|
?.map((e) => (e as num).toInt())
|
||||||
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$ReceivedRecoveryShareToJson(
|
Map<String, dynamic> _$ReceivedRecoveryShareToJson(
|
||||||
|
|
@ -27,6 +30,7 @@ Map<String, dynamic> _$ReceivedRecoveryShareToJson(
|
||||||
) => <String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'messageId': instance.messageId,
|
'messageId': instance.messageId,
|
||||||
'trustedFriendDisplayName': instance.trustedFriendDisplayName,
|
'trustedFriendDisplayName': instance.trustedFriendDisplayName,
|
||||||
|
'trustedFriendAvatarSvg': instance.trustedFriendAvatarSvg,
|
||||||
'myDisplayName': instance.myDisplayName,
|
'myDisplayName': instance.myDisplayName,
|
||||||
'myUserId': instance.myUserId,
|
'myUserId': instance.myUserId,
|
||||||
'myAvatarSvg': instance.myAvatarSvg,
|
'myAvatarSvg': instance.myAvatarSvg,
|
||||||
|
|
|
||||||
|
|
@ -72,7 +72,7 @@ class UserData {
|
||||||
bool videoStabilizationEnabled = true;
|
bool videoStabilizationEnabled = true;
|
||||||
|
|
||||||
@JsonKey(defaultValue: true)
|
@JsonKey(defaultValue: true)
|
||||||
bool showFeedbackShortcut = true;
|
bool showNewsShortcut = true;
|
||||||
|
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool showShowImagePreviewWhenSending = false;
|
bool showShowImagePreviewWhenSending = false;
|
||||||
|
|
@ -103,6 +103,9 @@ class UserData {
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
bool screenLockEnabled = false;
|
bool screenLockEnabled = false;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
bool isCloudBackupEnabled = false;
|
||||||
|
|
||||||
// > User Discovery Configurations
|
// > User Discovery Configurations
|
||||||
|
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
|
|
@ -139,6 +142,9 @@ class UserData {
|
||||||
@JsonKey(defaultValue: true)
|
@JsonKey(defaultValue: true)
|
||||||
bool hideChangeLog = true;
|
bool hideChangeLog = true;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: false)
|
||||||
|
bool hideMemoriesBackupPromo = false;
|
||||||
|
|
||||||
@JsonKey(defaultValue: true)
|
@JsonKey(defaultValue: true)
|
||||||
bool updateFCMToken = true;
|
bool updateFCMToken = true;
|
||||||
|
|
||||||
|
|
@ -195,8 +201,12 @@ class TwonlySafeBackup {
|
||||||
factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) =>
|
factory TwonlySafeBackup.fromJson(Map<String, dynamic> json) =>
|
||||||
_$TwonlySafeBackupFromJson(json);
|
_$TwonlySafeBackupFromJson(json);
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: 0)
|
||||||
int lastBackupSize = 0;
|
int lastBackupSize = 0;
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: LastBackupUploadState.none)
|
||||||
LastBackupUploadState backupUploadState = LastBackupUploadState.none;
|
LastBackupUploadState backupUploadState = LastBackupUploadState.none;
|
||||||
|
|
||||||
DateTime? lastBackupDone;
|
DateTime? lastBackupDone;
|
||||||
List<int> backupId;
|
List<int> backupId;
|
||||||
List<int> encryptionKey;
|
List<int> encryptionKey;
|
||||||
|
|
@ -213,17 +223,13 @@ class PasswordLessRecovery {
|
||||||
// Only stored, so the user can see his deposit email address...
|
// Only stored, so the user can see his deposit email address...
|
||||||
String? email;
|
String? email;
|
||||||
|
|
||||||
// <--
|
|
||||||
// Data shared with trusted friends
|
// Data shared with trusted friends
|
||||||
|
|
||||||
|
@JsonKey(defaultValue: 2)
|
||||||
int threshold;
|
int threshold;
|
||||||
// Trusted friends are able to brute-force the pin -> Server delets after X tries
|
// Used to derive the key from the email/pin
|
||||||
List<int>? pinSeed;
|
List<int>? serverKeyProtection;
|
||||||
List<int>? pinUnlockToken;
|
List<int>? pinUnlockToken;
|
||||||
|
|
||||||
// Stored not on the server, so the server is unable to link a email to a user until the actuall recovery or can
|
|
||||||
// brute-force the pin
|
|
||||||
List<int>? encryptedServerKeyNonce;
|
|
||||||
// --->
|
// --->
|
||||||
|
|
||||||
// Checking with the server that the server data is valid and not delted throug the pin protection for example.
|
// Checking with the server that the server data is valid and not delted throug the pin protection for example.
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
|
||||||
json['automaticallyMarkEqualMediaFilesAsOpened'] as bool? ?? false
|
json['automaticallyMarkEqualMediaFilesAsOpened'] as bool? ?? false
|
||||||
..videoStabilizationEnabled =
|
..videoStabilizationEnabled =
|
||||||
json['videoStabilizationEnabled'] as bool? ?? true
|
json['videoStabilizationEnabled'] as bool? ?? true
|
||||||
..showFeedbackShortcut = json['showFeedbackShortcut'] as bool? ?? true
|
..showNewsShortcut = json['showNewsShortcut'] as bool? ?? true
|
||||||
..showShowImagePreviewWhenSending =
|
..showShowImagePreviewWhenSending =
|
||||||
json['showShowImagePreviewWhenSending'] as bool? ?? false
|
json['showShowImagePreviewWhenSending'] as bool? ?? false
|
||||||
..startWithCameraOpen = json['startWithCameraOpen'] as bool? ?? true
|
..startWithCameraOpen = json['startWithCameraOpen'] as bool? ?? true
|
||||||
|
|
@ -68,6 +68,7 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
|
||||||
..allowErrorTrackingViaSentry =
|
..allowErrorTrackingViaSentry =
|
||||||
json['allowErrorTrackingViaSentry'] as bool? ?? false
|
json['allowErrorTrackingViaSentry'] as bool? ?? false
|
||||||
..screenLockEnabled = json['screenLockEnabled'] as bool? ?? false
|
..screenLockEnabled = json['screenLockEnabled'] as bool? ?? false
|
||||||
|
..isCloudBackupEnabled = json['isCloudBackupEnabled'] as bool? ?? false
|
||||||
..isUserDiscoveryEnabled =
|
..isUserDiscoveryEnabled =
|
||||||
json['isUserDiscoveryEnabled'] as bool? ?? false
|
json['isUserDiscoveryEnabled'] as bool? ?? false
|
||||||
..requiredSendImages = (json['requiredSendImages'] as num?)?.toInt() ?? 4
|
..requiredSendImages = (json['requiredSendImages'] as num?)?.toInt() ?? 4
|
||||||
|
|
@ -88,6 +89,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
|
||||||
?.map((e) => (e as num).toInt())
|
?.map((e) => (e as num).toInt())
|
||||||
.toList()
|
.toList()
|
||||||
..hideChangeLog = json['hideChangeLog'] as bool? ?? true
|
..hideChangeLog = json['hideChangeLog'] as bool? ?? true
|
||||||
|
..hideMemoriesBackupPromo =
|
||||||
|
json['hideMemoriesBackupPromo'] as bool? ?? false
|
||||||
..updateFCMToken = json['updateFCMToken'] as bool? ?? true
|
..updateFCMToken = json['updateFCMToken'] as bool? ?? true
|
||||||
..canUseLoginTokenForAuth =
|
..canUseLoginTokenForAuth =
|
||||||
json['canUseLoginTokenForAuth'] as bool? ?? true
|
json['canUseLoginTokenForAuth'] as bool? ?? true
|
||||||
|
|
@ -139,7 +142,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
|
||||||
'automaticallyMarkEqualMediaFilesAsOpened':
|
'automaticallyMarkEqualMediaFilesAsOpened':
|
||||||
instance.automaticallyMarkEqualMediaFilesAsOpened,
|
instance.automaticallyMarkEqualMediaFilesAsOpened,
|
||||||
'videoStabilizationEnabled': instance.videoStabilizationEnabled,
|
'videoStabilizationEnabled': instance.videoStabilizationEnabled,
|
||||||
'showFeedbackShortcut': instance.showFeedbackShortcut,
|
'showNewsShortcut': instance.showNewsShortcut,
|
||||||
'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending,
|
'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending,
|
||||||
'startWithCameraOpen': instance.startWithCameraOpen,
|
'startWithCameraOpen': instance.startWithCameraOpen,
|
||||||
'preSelectedEmojies': instance.preSelectedEmojies,
|
'preSelectedEmojies': instance.preSelectedEmojies,
|
||||||
|
|
@ -153,6 +156,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
|
||||||
?.toIso8601String(),
|
?.toIso8601String(),
|
||||||
'allowErrorTrackingViaSentry': instance.allowErrorTrackingViaSentry,
|
'allowErrorTrackingViaSentry': instance.allowErrorTrackingViaSentry,
|
||||||
'screenLockEnabled': instance.screenLockEnabled,
|
'screenLockEnabled': instance.screenLockEnabled,
|
||||||
|
'isCloudBackupEnabled': instance.isCloudBackupEnabled,
|
||||||
'isUserDiscoveryEnabled': instance.isUserDiscoveryEnabled,
|
'isUserDiscoveryEnabled': instance.isUserDiscoveryEnabled,
|
||||||
'requiredSendImages': instance.requiredSendImages,
|
'requiredSendImages': instance.requiredSendImages,
|
||||||
'userDiscoveryThreshold': instance.userDiscoveryThreshold,
|
'userDiscoveryThreshold': instance.userDiscoveryThreshold,
|
||||||
|
|
@ -165,6 +169,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
|
||||||
'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart,
|
'currentSignedPreKeyIndexStart': instance.currentSignedPreKeyIndexStart,
|
||||||
'lastChangeLogHash': instance.lastChangeLogHash,
|
'lastChangeLogHash': instance.lastChangeLogHash,
|
||||||
'hideChangeLog': instance.hideChangeLog,
|
'hideChangeLog': instance.hideChangeLog,
|
||||||
|
'hideMemoriesBackupPromo': instance.hideMemoriesBackupPromo,
|
||||||
'updateFCMToken': instance.updateFCMToken,
|
'updateFCMToken': instance.updateFCMToken,
|
||||||
'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth,
|
'canUseLoginTokenForAuth': instance.canUseLoginTokenForAuth,
|
||||||
'twonlySafeBackup': instance.twonlySafeBackup,
|
'twonlySafeBackup': instance.twonlySafeBackup,
|
||||||
|
|
@ -202,11 +207,13 @@ TwonlySafeBackup _$TwonlySafeBackupFromJson(Map<String, dynamic> json) =>
|
||||||
.map((e) => (e as num).toInt())
|
.map((e) => (e as num).toInt())
|
||||||
.toList(),
|
.toList(),
|
||||||
)
|
)
|
||||||
..lastBackupSize = (json['lastBackupSize'] as num).toInt()
|
..lastBackupSize = (json['lastBackupSize'] as num?)?.toInt() ?? 0
|
||||||
..backupUploadState = $enumDecode(
|
..backupUploadState =
|
||||||
_$LastBackupUploadStateEnumMap,
|
$enumDecodeNullable(
|
||||||
json['backupUploadState'],
|
_$LastBackupUploadStateEnumMap,
|
||||||
)
|
json['backupUploadState'],
|
||||||
|
) ??
|
||||||
|
LastBackupUploadState.none
|
||||||
..lastBackupDone = json['lastBackupDone'] == null
|
..lastBackupDone = json['lastBackupDone'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(json['lastBackupDone'] as String);
|
: DateTime.parse(json['lastBackupDone'] as String);
|
||||||
|
|
@ -230,18 +237,14 @@ const _$LastBackupUploadStateEnumMap = {
|
||||||
|
|
||||||
PasswordLessRecovery _$PasswordLessRecoveryFromJson(
|
PasswordLessRecovery _$PasswordLessRecoveryFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => PasswordLessRecovery((json['threshold'] as num).toInt())
|
) => PasswordLessRecovery((json['threshold'] as num?)?.toInt() ?? 2)
|
||||||
..email = json['email'] as String?
|
..email = json['email'] as String?
|
||||||
..pinSeed = (json['pinSeed'] as List<dynamic>?)
|
..serverKeyProtection = (json['serverKeyProtection'] as List<dynamic>?)
|
||||||
?.map((e) => (e as num).toInt())
|
?.map((e) => (e as num).toInt())
|
||||||
.toList()
|
.toList()
|
||||||
..pinUnlockToken = (json['pinUnlockToken'] as List<dynamic>?)
|
..pinUnlockToken = (json['pinUnlockToken'] as List<dynamic>?)
|
||||||
?.map((e) => (e as num).toInt())
|
?.map((e) => (e as num).toInt())
|
||||||
.toList()
|
.toList()
|
||||||
..encryptedServerKeyNonce =
|
|
||||||
(json['encryptedServerKeyNonce'] as List<dynamic>?)
|
|
||||||
?.map((e) => (e as num).toInt())
|
|
||||||
.toList()
|
|
||||||
..lastServerHeartbeat = json['lastServerHeartbeat'] == null
|
..lastServerHeartbeat = json['lastServerHeartbeat'] == null
|
||||||
? null
|
? null
|
||||||
: DateTime.parse(json['lastServerHeartbeat'] as String)
|
: DateTime.parse(json['lastServerHeartbeat'] as String)
|
||||||
|
|
@ -257,9 +260,8 @@ Map<String, dynamic> _$PasswordLessRecoveryToJson(
|
||||||
) => <String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'email': instance.email,
|
'email': instance.email,
|
||||||
'threshold': instance.threshold,
|
'threshold': instance.threshold,
|
||||||
'pinSeed': instance.pinSeed,
|
'serverKeyProtection': instance.serverKeyProtection,
|
||||||
'pinUnlockToken': instance.pinUnlockToken,
|
'pinUnlockToken': instance.pinUnlockToken,
|
||||||
'encryptedServerKeyNonce': instance.encryptedServerKeyNonce,
|
|
||||||
'lastServerHeartbeat': instance.lastServerHeartbeat?.toIso8601String(),
|
'lastServerHeartbeat': instance.lastServerHeartbeat?.toIso8601String(),
|
||||||
'lastContactHeartbeat': instance.lastContactHeartbeat?.toIso8601String(),
|
'lastContactHeartbeat': instance.lastContactHeartbeat?.toIso8601String(),
|
||||||
'encryptedServerKey': instance.encryptedServerKey,
|
'encryptedServerKey': instance.encryptedServerKey,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -339,11 +339,11 @@ const Handshake_GetServerKeyForPasswordLessRecovery$json = {
|
||||||
'2': [
|
'2': [
|
||||||
{'1': 'user_id', '3': 1, '4': 1, '5': 3, '10': 'userId'},
|
{'1': 'user_id', '3': 1, '4': 1, '5': 3, '10': 'userId'},
|
||||||
{
|
{
|
||||||
'1': 'encrypted_server_key_none',
|
'1': 'server_key_protection',
|
||||||
'3': 2,
|
'3': 2,
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 12,
|
'5': 12,
|
||||||
'10': 'encryptedServerKeyNone'
|
'10': 'serverKeyProtection'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'1': 'pin_unlock_token',
|
'1': 'pin_unlock_token',
|
||||||
|
|
@ -462,20 +462,20 @@ final $typed_data.Uint8List handshakeDescriptor = $convert.base64Decode(
|
||||||
'VXaXRoTG9naW5Ub2tlbhIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSLAoSc2VjcmV0X2xvZ2lu'
|
'VXaXRoTG9naW5Ub2tlbhIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSLAoSc2VjcmV0X2xvZ2lu'
|
||||||
'X3Rva2VuGAIgASgMUhBzZWNyZXRMb2dpblRva2VuEh8KC2FwcF92ZXJzaW9uGAMgASgJUgphcH'
|
'X3Rva2VuGAIgASgMUhBzZWNyZXRMb2dpblRva2VuEh8KC2FwcF92ZXJzaW9uGAMgASgJUgphcH'
|
||||||
'BWZXJzaW9uEhsKCWRldmljZV9pZBgEIAEoA1IIZGV2aWNlSWQSIwoNaW5fYmFja2dyb3VuZBgF'
|
'BWZXJzaW9uEhsKCWRldmljZV9pZBgEIAEoA1IIZGV2aWNlSWQSIwoNaW5fYmFja2dyb3VuZBgF'
|
||||||
'IAEoCFIMaW5CYWNrZ3JvdW5kGqwCCiNHZXRTZXJ2ZXJLZXlGb3JQYXNzd29yZExlc3NSZWNvdm'
|
'IAEoCFIMaW5CYWNrZ3JvdW5kGqUCCiNHZXRTZXJ2ZXJLZXlGb3JQYXNzd29yZExlc3NSZWNvdm'
|
||||||
'VyeRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSOQoZZW5jcnlwdGVkX3NlcnZlcl9rZXlfbm9u'
|
'VyeRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSMgoVc2VydmVyX2tleV9wcm90ZWN0aW9uGAIg'
|
||||||
'ZRgCIAEoDFIWZW5jcnlwdGVkU2VydmVyS2V5Tm9uZRItChBwaW5fdW5sb2NrX3Rva2VuGAMgAS'
|
'ASgMUhNzZXJ2ZXJLZXlQcm90ZWN0aW9uEi0KEHBpbl91bmxvY2tfdG9rZW4YAyABKAxIAFIOcG'
|
||||||
'gMSABSDnBpblVubG9ja1Rva2VuiAEBEjEKEnBpbl9wcm90ZWN0aW9uX2tleRgEIAEoDEgBUhBw'
|
'luVW5sb2NrVG9rZW6IAQESMQoScGluX3Byb3RlY3Rpb25fa2V5GAQgASgMSAFSEHBpblByb3Rl'
|
||||||
'aW5Qcm90ZWN0aW9uS2V5iAEBEhkKBWVtYWlsGAUgASgJSAJSBWVtYWlsiAEBQhMKEV9waW5fdW'
|
'Y3Rpb25LZXmIAQESGQoFZW1haWwYBSABKAlIAlIFZW1haWyIAQFCEwoRX3Bpbl91bmxvY2tfdG'
|
||||||
'5sb2NrX3Rva2VuQhUKE19waW5fcHJvdGVjdGlvbl9rZXlCCAoGX2VtYWlsGssBCiBSZWdpc3Rl'
|
'9rZW5CFQoTX3Bpbl9wcm90ZWN0aW9uX2tleUIICgZfZW1haWwaywEKIFJlZ2lzdGVyUGFzc3dv'
|
||||||
'clBhc3N3b3JkbGVzc05vdGlmaWNhdGlvbhInCg9ub3RpZmljYXRpb25faWQYASABKAlSDm5vdG'
|
'cmRsZXNzTm90aWZpY2F0aW9uEicKD25vdGlmaWNhdGlvbl9pZBgBIAEoCVIObm90aWZpY2F0aW'
|
||||||
'lmaWNhdGlvbklkEi4KE2Rvd25sb2FkX2F1dGhfdG9rZW4YAiABKAxSEWRvd25sb2FkQXV0aFRv'
|
'9uSWQSLgoTZG93bmxvYWRfYXV0aF90b2tlbhgCIAEoDFIRZG93bmxvYWRBdXRoVG9rZW4SGwoJ'
|
||||||
'a2VuEhsKCWxhbmdfY29kZRgDIAEoCVIIbGFuZ0NvZGUSIgoKZ29vZ2xlX2ZjbRgEIAEoCUgAUg'
|
'bGFuZ19jb2RlGAMgASgJUghsYW5nQ29kZRIiCgpnb29nbGVfZmNtGAQgASgJSABSCWdvb2dsZU'
|
||||||
'lnb29nbGVGY22IAQFCDQoLX2dvb2dsZV9mY20avAEKIENoZWNrRm9yUGFzc3dvcmRsZXNzTm90'
|
'ZjbYgBAUINCgtfZ29vZ2xlX2ZjbRq8AQogQ2hlY2tGb3JQYXNzd29yZGxlc3NOb3RpZmljYXRp'
|
||||||
'aWZpY2F0aW9uEicKD25vdGlmaWNhdGlvbl9pZBgBIAEoCVIObm90aWZpY2F0aW9uSWQSLgoTZG'
|
'b24SJwoPbm90aWZpY2F0aW9uX2lkGAEgASgJUg5ub3RpZmljYXRpb25JZBIuChNkb3dubG9hZF'
|
||||||
'93bmxvYWRfYXV0aF90b2tlbhgCIAEoDFIRZG93bmxvYWRBdXRoVG9rZW4SPwocYWxyZWFkeV9y'
|
'9hdXRoX3Rva2VuGAIgASgMUhFkb3dubG9hZEF1dGhUb2tlbhI/ChxhbHJlYWR5X3JlY2VpdmVk'
|
||||||
'ZWNlaXZlZF9tZXNzYWdlX2lkcxgDIAMoA1IZYWxyZWFkeVJlY2VpdmVkTWVzc2FnZUlkc0ILCg'
|
'X21lc3NhZ2VfaWRzGAMgAygDUhlhbHJlYWR5UmVjZWl2ZWRNZXNzYWdlSWRzQgsKCUhhbmRzaG'
|
||||||
'lIYW5kc2hha2U=');
|
'FrZQ==');
|
||||||
|
|
||||||
@$core.Deprecated('Use applicationDataDescriptor instead')
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
const ApplicationData$json = {
|
const ApplicationData$json = {
|
||||||
|
|
@ -526,15 +526,6 @@ const ApplicationData$json = {
|
||||||
'9': 0,
|
'9': 0,
|
||||||
'10': 'updateGoogleFcmToken'
|
'10': 'updateGoogleFcmToken'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'1': 'deprecated_9',
|
|
||||||
'3': 9,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.client_to_server.ApplicationData.Deprecated',
|
|
||||||
'9': 0,
|
|
||||||
'10': 'deprecated9'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
'1': 'getCurrentPlanInfos',
|
'1': 'getCurrentPlanInfos',
|
||||||
'3': 10,
|
'3': 10,
|
||||||
|
|
@ -544,15 +535,6 @@ const ApplicationData$json = {
|
||||||
'9': 0,
|
'9': 0,
|
||||||
'10': 'getCurrentPlanInfos'
|
'10': 'getCurrentPlanInfos'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'1': 'deprecated_11',
|
|
||||||
'3': 11,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.client_to_server.ApplicationData.Deprecated',
|
|
||||||
'9': 0,
|
|
||||||
'10': 'deprecated11'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
'1': 'getAvailablePlans',
|
'1': 'getAvailablePlans',
|
||||||
'3': 12,
|
'3': 12,
|
||||||
|
|
@ -562,60 +544,6 @@ const ApplicationData$json = {
|
||||||
'9': 0,
|
'9': 0,
|
||||||
'10': 'getAvailablePlans'
|
'10': 'getAvailablePlans'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
'1': 'deprecated_13',
|
|
||||||
'3': 13,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.client_to_server.ApplicationData.Deprecated',
|
|
||||||
'9': 0,
|
|
||||||
'10': 'deprecated13'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'1': 'deprecated_14',
|
|
||||||
'3': 14,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.client_to_server.ApplicationData.Deprecated',
|
|
||||||
'9': 0,
|
|
||||||
'10': 'deprecated14'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'1': 'deprecated_15',
|
|
||||||
'3': 15,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.client_to_server.ApplicationData.Deprecated',
|
|
||||||
'9': 0,
|
|
||||||
'10': 'deprecated15'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'1': 'deprecated_16',
|
|
||||||
'3': 16,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.client_to_server.ApplicationData.Deprecated',
|
|
||||||
'9': 0,
|
|
||||||
'10': 'deprecated16'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'1': 'deprecated_17',
|
|
||||||
'3': 17,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.client_to_server.ApplicationData.Deprecated',
|
|
||||||
'9': 0,
|
|
||||||
'10': 'deprecated17'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'1': 'deprecated_19',
|
|
||||||
'3': 19,
|
|
||||||
'4': 1,
|
|
||||||
'5': 11,
|
|
||||||
'6': '.client_to_server.ApplicationData.Deprecated',
|
|
||||||
'9': 0,
|
|
||||||
'10': 'deprecated19'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
'1': 'downloadDone',
|
'1': 'downloadDone',
|
||||||
'3': 20,
|
'3': 20,
|
||||||
|
|
@ -733,6 +661,69 @@ const ApplicationData$json = {
|
||||||
'9': 0,
|
'9': 0,
|
||||||
'10': 'passwordlessNotification'
|
'10': 'passwordlessNotification'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'1': 'request_memories_upload',
|
||||||
|
'3': 33,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.client_to_server.ApplicationData.RequestMemoriesUpload',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'requestMemoriesUpload'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'confirm_memories_upload',
|
||||||
|
'3': 34,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.client_to_server.ApplicationData.ConfirmMemoriesUpload',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'confirmMemoriesUpload'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'get_memories_list',
|
||||||
|
'3': 35,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.client_to_server.ApplicationData.GetMemoriesList',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'getMemoriesList'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'get_memories_url',
|
||||||
|
'3': 36,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.client_to_server.ApplicationData.GetMemoriesUrl',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'getMemoriesUrl'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'get_memories_usage',
|
||||||
|
'3': 37,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.client_to_server.ApplicationData.GetMemoriesUsage',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'getMemoriesUsage'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'delete_memory',
|
||||||
|
'3': 38,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.client_to_server.ApplicationData.DeleteMemory',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'deleteMemory'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'disable_memories_backup',
|
||||||
|
'3': 39,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.client_to_server.ApplicationData.DisableMemoriesBackup',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'disableMemoriesBackup'
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'3': [
|
'3': [
|
||||||
ApplicationData_TextMessage$json,
|
ApplicationData_TextMessage$json,
|
||||||
|
|
@ -741,7 +732,6 @@ const ApplicationData$json = {
|
||||||
ApplicationData_UpdateGoogleFcmToken$json,
|
ApplicationData_UpdateGoogleFcmToken$json,
|
||||||
ApplicationData_GetUserById$json,
|
ApplicationData_GetUserById$json,
|
||||||
ApplicationData_GetAvailablePlans$json,
|
ApplicationData_GetAvailablePlans$json,
|
||||||
ApplicationData_GetAddAccountsInvites$json,
|
|
||||||
ApplicationData_GetCurrentPlanInfos$json,
|
ApplicationData_GetCurrentPlanInfos$json,
|
||||||
ApplicationData_RemoveAdditionalUser$json,
|
ApplicationData_RemoveAdditionalUser$json,
|
||||||
ApplicationData_GetPrekeysByUserId$json,
|
ApplicationData_GetPrekeysByUserId$json,
|
||||||
|
|
@ -754,13 +744,25 @@ const ApplicationData$json = {
|
||||||
ApplicationData_DeleteAccount$json,
|
ApplicationData_DeleteAccount$json,
|
||||||
ApplicationData_AddAdditionalUser$json,
|
ApplicationData_AddAdditionalUser$json,
|
||||||
ApplicationData_SetLoginToken$json,
|
ApplicationData_SetLoginToken$json,
|
||||||
ApplicationData_Deprecated$json,
|
|
||||||
ApplicationData_RegisterPasswordLessRecovery$json,
|
ApplicationData_RegisterPasswordLessRecovery$json,
|
||||||
ApplicationData_PasswordlessNotification$json
|
ApplicationData_PasswordlessNotification$json,
|
||||||
|
ApplicationData_RequestMemoriesUpload$json,
|
||||||
|
ApplicationData_ConfirmMemoriesUpload$json,
|
||||||
|
ApplicationData_GetMemoriesList$json,
|
||||||
|
ApplicationData_GetMemoriesUrl$json,
|
||||||
|
ApplicationData_GetMemoriesUsage$json,
|
||||||
|
ApplicationData_DeleteMemory$json,
|
||||||
|
ApplicationData_DisableMemoriesBackup$json
|
||||||
],
|
],
|
||||||
'8': [
|
'8': [
|
||||||
{'1': 'ApplicationData'},
|
{'1': 'ApplicationData'},
|
||||||
],
|
],
|
||||||
|
'9': [
|
||||||
|
{'1': 9, '2': 10},
|
||||||
|
{'1': 11, '2': 12},
|
||||||
|
{'1': 13, '2': 18},
|
||||||
|
{'1': 19, '2': 20},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@$core.Deprecated('Use applicationDataDescriptor instead')
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
|
|
@ -821,11 +823,6 @@ const ApplicationData_GetAvailablePlans$json = {
|
||||||
'1': 'GetAvailablePlans',
|
'1': 'GetAvailablePlans',
|
||||||
};
|
};
|
||||||
|
|
||||||
@$core.Deprecated('Use applicationDataDescriptor instead')
|
|
||||||
const ApplicationData_GetAddAccountsInvites$json = {
|
|
||||||
'1': 'GetAddAccountsInvites',
|
|
||||||
};
|
|
||||||
|
|
||||||
@$core.Deprecated('Use applicationDataDescriptor instead')
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
const ApplicationData_GetCurrentPlanInfos$json = {
|
const ApplicationData_GetCurrentPlanInfos$json = {
|
||||||
'1': 'GetCurrentPlanInfos',
|
'1': 'GetCurrentPlanInfos',
|
||||||
|
|
@ -930,11 +927,6 @@ const ApplicationData_SetLoginToken$json = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@$core.Deprecated('Use applicationDataDescriptor instead')
|
|
||||||
const ApplicationData_Deprecated$json = {
|
|
||||||
'1': 'Deprecated',
|
|
||||||
};
|
|
||||||
|
|
||||||
@$core.Deprecated('Use applicationDataDescriptor instead')
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
const ApplicationData_RegisterPasswordLessRecovery$json = {
|
const ApplicationData_RegisterPasswordLessRecovery$json = {
|
||||||
'1': 'RegisterPasswordLessRecovery',
|
'1': 'RegisterPasswordLessRecovery',
|
||||||
|
|
@ -976,6 +968,60 @@ const ApplicationData_PasswordlessNotification$json = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
|
const ApplicationData_RequestMemoriesUpload$json = {
|
||||||
|
'1': 'RequestMemoriesUpload',
|
||||||
|
'2': [
|
||||||
|
{'1': 'size', '3': 1, '4': 1, '5': 3, '10': 'size'},
|
||||||
|
{'1': 'original_date', '3': 2, '4': 1, '5': 3, '10': 'originalDate'},
|
||||||
|
{'1': 'media_id', '3': 3, '4': 1, '5': 9, '10': 'mediaId'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
|
const ApplicationData_ConfirmMemoriesUpload$json = {
|
||||||
|
'1': 'ConfirmMemoriesUpload',
|
||||||
|
'2': [
|
||||||
|
{'1': 'media_id', '3': 1, '4': 1, '5': 9, '10': 'mediaId'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
|
const ApplicationData_GetMemoriesList$json = {
|
||||||
|
'1': 'GetMemoriesList',
|
||||||
|
'2': [
|
||||||
|
{'1': 'offset_date', '3': 1, '4': 1, '5': 3, '10': 'offsetDate'},
|
||||||
|
{'1': 'limit', '3': 2, '4': 1, '5': 3, '10': 'limit'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
|
const ApplicationData_GetMemoriesUrl$json = {
|
||||||
|
'1': 'GetMemoriesUrl',
|
||||||
|
'2': [
|
||||||
|
{'1': 'media_id', '3': 1, '4': 1, '5': 9, '10': 'mediaId'},
|
||||||
|
{'1': 'thumbnail', '3': 2, '4': 1, '5': 8, '10': 'thumbnail'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
|
const ApplicationData_GetMemoriesUsage$json = {
|
||||||
|
'1': 'GetMemoriesUsage',
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
|
const ApplicationData_DeleteMemory$json = {
|
||||||
|
'1': 'DeleteMemory',
|
||||||
|
'2': [
|
||||||
|
{'1': 'media_id', '3': 1, '4': 1, '5': 9, '10': 'mediaId'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use applicationDataDescriptor instead')
|
||||||
|
const ApplicationData_DisableMemoriesBackup$json = {
|
||||||
|
'1': 'DisableMemoriesBackup',
|
||||||
|
};
|
||||||
|
|
||||||
/// Descriptor for `ApplicationData`. Decode as a `google.protobuf.DescriptorProto`.
|
/// Descriptor for `ApplicationData`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
final $typed_data.Uint8List applicationDataDescriptor = $convert.base64Decode(
|
final $typed_data.Uint8List applicationDataDescriptor = $convert.base64Decode(
|
||||||
'Cg9BcHBsaWNhdGlvbkRhdGESUQoLdGV4dE1lc3NhZ2UYASABKAsyLS5jbGllbnRfdG9fc2Vydm'
|
'Cg9BcHBsaWNhdGlvbkRhdGESUQoLdGV4dE1lc3NhZ2UYASABKAsyLS5jbGllbnRfdG9fc2Vydm'
|
||||||
|
|
@ -986,71 +1032,79 @@ final $typed_data.Uint8List applicationDataDescriptor = $convert.base64Decode(
|
||||||
'VzZXJJZEgAUhJnZXRQcmVrZXlzQnlVc2VySWQSUQoLZ2V0VXNlckJ5SWQYBiABKAsyLS5jbGll'
|
'VzZXJJZEgAUhJnZXRQcmVrZXlzQnlVc2VySWQSUQoLZ2V0VXNlckJ5SWQYBiABKAsyLS5jbGll'
|
||||||
'bnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5HZXRVc2VyQnlJZEgAUgtnZXRVc2VyQnlJZB'
|
'bnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5HZXRVc2VyQnlJZEgAUgtnZXRVc2VyQnlJZB'
|
||||||
'JsChR1cGRhdGVHb29nbGVGY21Ub2tlbhgIIAEoCzI2LmNsaWVudF90b19zZXJ2ZXIuQXBwbGlj'
|
'JsChR1cGRhdGVHb29nbGVGY21Ub2tlbhgIIAEoCzI2LmNsaWVudF90b19zZXJ2ZXIuQXBwbGlj'
|
||||||
'YXRpb25EYXRhLlVwZGF0ZUdvb2dsZUZjbVRva2VuSABSFHVwZGF0ZUdvb2dsZUZjbVRva2VuEl'
|
'YXRpb25EYXRhLlVwZGF0ZUdvb2dsZUZjbVRva2VuSABSFHVwZGF0ZUdvb2dsZUZjbVRva2VuEm'
|
||||||
'EKDGRlcHJlY2F0ZWRfORgJIAEoCzIsLmNsaWVudF90b19zZXJ2ZXIuQXBwbGljYXRpb25EYXRh'
|
'kKE2dldEN1cnJlbnRQbGFuSW5mb3MYCiABKAsyNS5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0'
|
||||||
'LkRlcHJlY2F0ZWRIAFILZGVwcmVjYXRlZDkSaQoTZ2V0Q3VycmVudFBsYW5JbmZvcxgKIAEoCz'
|
'aW9uRGF0YS5HZXRDdXJyZW50UGxhbkluZm9zSABSE2dldEN1cnJlbnRQbGFuSW5mb3MSYwoRZ2'
|
||||||
'I1LmNsaWVudF90b19zZXJ2ZXIuQXBwbGljYXRpb25EYXRhLkdldEN1cnJlbnRQbGFuSW5mb3NI'
|
'V0QXZhaWxhYmxlUGxhbnMYDCABKAsyMy5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0'
|
||||||
'AFITZ2V0Q3VycmVudFBsYW5JbmZvcxJTCg1kZXByZWNhdGVkXzExGAsgASgLMiwuY2xpZW50X3'
|
'YS5HZXRBdmFpbGFibGVQbGFuc0gAUhFnZXRBdmFpbGFibGVQbGFucxJUCgxkb3dubG9hZERvbm'
|
||||||
'RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuRGVwcmVjYXRlZEgAUgxkZXByZWNhdGVkMTESYwoR'
|
'UYFCABKAsyLi5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5Eb3dubG9hZERvbmVI'
|
||||||
'Z2V0QXZhaWxhYmxlUGxhbnMYDCABKAsyMy5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRG'
|
'AFIMZG93bmxvYWREb25lEnUKF2dldFNpZ25lZFByZWtleUJ5VXNlcmlkGBYgASgLMjkuY2xpZW'
|
||||||
'F0YS5HZXRBdmFpbGFibGVQbGFuc0gAUhFnZXRBdmFpbGFibGVQbGFucxJTCg1kZXByZWNhdGVk'
|
'50X3RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuR2V0U2lnbmVkUHJlS2V5QnlVc2VySWRIAFIX'
|
||||||
'XzEzGA0gASgLMiwuY2xpZW50X3RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuRGVwcmVjYXRlZE'
|
'Z2V0U2lnbmVkUHJla2V5QnlVc2VyaWQSZgoSdXBkYXRlU2lnbmVkUHJla2V5GBcgASgLMjQuY2'
|
||||||
'gAUgxkZXByZWNhdGVkMTMSUwoNZGVwcmVjYXRlZF8xNBgOIAEoCzIsLmNsaWVudF90b19zZXJ2'
|
'xpZW50X3RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuVXBkYXRlU2lnbmVkUHJlS2V5SABSEnVw'
|
||||||
'ZXIuQXBwbGljYXRpb25EYXRhLkRlcHJlY2F0ZWRIAFIMZGVwcmVjYXRlZDE0ElMKDWRlcHJlY2'
|
'ZGF0ZVNpZ25lZFByZWtleRJXCg1kZWxldGVBY2NvdW50GBggASgLMi8uY2xpZW50X3RvX3Nlcn'
|
||||||
'F0ZWRfMTUYDyABKAsyLC5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5EZXByZWNh'
|
'Zlci5BcHBsaWNhdGlvbkRhdGEuRGVsZXRlQWNjb3VudEgAUg1kZWxldGVBY2NvdW50Ek4KCnJl'
|
||||||
'dGVkSABSDGRlcHJlY2F0ZWQxNRJTCg1kZXByZWNhdGVkXzE2GBAgASgLMiwuY2xpZW50X3RvX3'
|
'cG9ydFVzZXIYGSABKAsyLC5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5SZXBvcn'
|
||||||
'NlcnZlci5BcHBsaWNhdGlvbkRhdGEuRGVwcmVjYXRlZEgAUgxkZXByZWNhdGVkMTYSUwoNZGVw'
|
'RVc2VySABSCnJlcG9ydFVzZXISWgoOY2hhbmdlVXNlcm5hbWUYGiABKAsyMC5jbGllbnRfdG9f'
|
||||||
'cmVjYXRlZF8xNxgRIAEoCzIsLmNsaWVudF90b19zZXJ2ZXIuQXBwbGljYXRpb25EYXRhLkRlcH'
|
'c2VydmVyLkFwcGxpY2F0aW9uRGF0YS5DaGFuZ2VVc2VybmFtZUgAUg5jaGFuZ2VVc2VybmFtZR'
|
||||||
'JlY2F0ZWRIAFIMZGVwcmVjYXRlZDE3ElMKDWRlcHJlY2F0ZWRfMTkYEyABKAsyLC5jbGllbnRf'
|
'JRCgtpcGFQdXJjaGFzZRgbIAEoCzItLmNsaWVudF90b19zZXJ2ZXIuQXBwbGljYXRpb25EYXRh'
|
||||||
'dG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5EZXByZWNhdGVkSABSDGRlcHJlY2F0ZWQxORJUCg'
|
'LklQQVB1cmNoYXNlSABSC2lwYVB1cmNoYXNlElcKDWlwYUZvcmNlQ2hlY2sYHCABKAsyLy5jbG'
|
||||||
'xkb3dubG9hZERvbmUYFCABKAsyLi5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5E'
|
'llbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5JUEFGb3JjZUNoZWNrSABSDWlwYUZvcmNl'
|
||||||
'b3dubG9hZERvbmVIAFIMZG93bmxvYWREb25lEnUKF2dldFNpZ25lZFByZWtleUJ5VXNlcmlkGB'
|
'Q2hlY2sSbAoUcmVtb3ZlQWRkaXRpb25hbFVzZXIYEiABKAsyNi5jbGllbnRfdG9fc2VydmVyLk'
|
||||||
'YgASgLMjkuY2xpZW50X3RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuR2V0U2lnbmVkUHJlS2V5'
|
'FwcGxpY2F0aW9uRGF0YS5SZW1vdmVBZGRpdGlvbmFsVXNlckgAUhRyZW1vdmVBZGRpdGlvbmFs'
|
||||||
'QnlVc2VySWRIAFIXZ2V0U2lnbmVkUHJla2V5QnlVc2VyaWQSZgoSdXBkYXRlU2lnbmVkUHJla2'
|
'VXNlchJjChFhZGRBZGRpdGlvbmFsVXNlchgdIAEoCzIzLmNsaWVudF90b19zZXJ2ZXIuQXBwbG'
|
||||||
'V5GBcgASgLMjQuY2xpZW50X3RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuVXBkYXRlU2lnbmVk'
|
'ljYXRpb25EYXRhLkFkZEFkZGl0aW9uYWxVc2VySABSEWFkZEFkZGl0aW9uYWxVc2VyElkKD3Nl'
|
||||||
'UHJlS2V5SABSEnVwZGF0ZVNpZ25lZFByZWtleRJXCg1kZWxldGVBY2NvdW50GBggASgLMi8uY2'
|
'dF9sb2dpbl90b2tlbhgeIAEoCzIvLmNsaWVudF90b19zZXJ2ZXIuQXBwbGljYXRpb25EYXRhLl'
|
||||||
'xpZW50X3RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuRGVsZXRlQWNjb3VudEgAUg1kZWxldGVB'
|
'NldExvZ2luVG9rZW5IAFINc2V0TG9naW5Ub2tlbhKGAQoecmVnaXN0ZXJfcGFzc3dvcmRsZXNz'
|
||||||
'Y2NvdW50Ek4KCnJlcG9ydFVzZXIYGSABKAsyLC5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW'
|
'X3JlY292ZXJ5GB8gASgLMj4uY2xpZW50X3RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuUmVnaX'
|
||||||
'9uRGF0YS5SZXBvcnRVc2VySABSCnJlcG9ydFVzZXISWgoOY2hhbmdlVXNlcm5hbWUYGiABKAsy'
|
'N0ZXJQYXNzd29yZExlc3NSZWNvdmVyeUgAUhxyZWdpc3RlclBhc3N3b3JkbGVzc1JlY292ZXJ5'
|
||||||
'MC5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5DaGFuZ2VVc2VybmFtZUgAUg5jaG'
|
'EnkKGXBhc3N3b3JkbGVzc19ub3RpZmljYXRpb24YICABKAsyOi5jbGllbnRfdG9fc2VydmVyLk'
|
||||||
'FuZ2VVc2VybmFtZRJRCgtpcGFQdXJjaGFzZRgbIAEoCzItLmNsaWVudF90b19zZXJ2ZXIuQXBw'
|
'FwcGxpY2F0aW9uRGF0YS5QYXNzd29yZGxlc3NOb3RpZmljYXRpb25IAFIYcGFzc3dvcmRsZXNz'
|
||||||
'bGljYXRpb25EYXRhLklQQVB1cmNoYXNlSABSC2lwYVB1cmNoYXNlElcKDWlwYUZvcmNlQ2hlY2'
|
'Tm90aWZpY2F0aW9uEnEKF3JlcXVlc3RfbWVtb3JpZXNfdXBsb2FkGCEgASgLMjcuY2xpZW50X3'
|
||||||
'sYHCABKAsyLy5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5JUEFGb3JjZUNoZWNr'
|
'RvX3NlcnZlci5BcHBsaWNhdGlvbkRhdGEuUmVxdWVzdE1lbW9yaWVzVXBsb2FkSABSFXJlcXVl'
|
||||||
'SABSDWlwYUZvcmNlQ2hlY2sSbAoUcmVtb3ZlQWRkaXRpb25hbFVzZXIYEiABKAsyNi5jbGllbn'
|
'c3RNZW1vcmllc1VwbG9hZBJxChdjb25maXJtX21lbW9yaWVzX3VwbG9hZBgiIAEoCzI3LmNsaW'
|
||||||
'RfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5SZW1vdmVBZGRpdGlvbmFsVXNlckgAUhRyZW1v'
|
'VudF90b19zZXJ2ZXIuQXBwbGljYXRpb25EYXRhLkNvbmZpcm1NZW1vcmllc1VwbG9hZEgAUhVj'
|
||||||
'dmVBZGRpdGlvbmFsVXNlchJjChFhZGRBZGRpdGlvbmFsVXNlchgdIAEoCzIzLmNsaWVudF90b1'
|
'b25maXJtTWVtb3JpZXNVcGxvYWQSXwoRZ2V0X21lbW9yaWVzX2xpc3QYIyABKAsyMS5jbGllbn'
|
||||||
'9zZXJ2ZXIuQXBwbGljYXRpb25EYXRhLkFkZEFkZGl0aW9uYWxVc2VySABSEWFkZEFkZGl0aW9u'
|
'RfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5HZXRNZW1vcmllc0xpc3RIAFIPZ2V0TWVtb3Jp'
|
||||||
'YWxVc2VyElkKD3NldF9sb2dpbl90b2tlbhgeIAEoCzIvLmNsaWVudF90b19zZXJ2ZXIuQXBwbG'
|
'ZXNMaXN0ElwKEGdldF9tZW1vcmllc191cmwYJCABKAsyMC5jbGllbnRfdG9fc2VydmVyLkFwcG'
|
||||||
'ljYXRpb25EYXRhLlNldExvZ2luVG9rZW5IAFINc2V0TG9naW5Ub2tlbhKGAQoecmVnaXN0ZXJf'
|
'xpY2F0aW9uRGF0YS5HZXRNZW1vcmllc1VybEgAUg5nZXRNZW1vcmllc1VybBJiChJnZXRfbWVt'
|
||||||
'cGFzc3dvcmRsZXNzX3JlY292ZXJ5GB8gASgLMj4uY2xpZW50X3RvX3NlcnZlci5BcHBsaWNhdG'
|
'b3JpZXNfdXNhZ2UYJSABKAsyMi5jbGllbnRfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5HZX'
|
||||||
'lvbkRhdGEuUmVnaXN0ZXJQYXNzd29yZExlc3NSZWNvdmVyeUgAUhxyZWdpc3RlclBhc3N3b3Jk'
|
'RNZW1vcmllc1VzYWdlSABSEGdldE1lbW9yaWVzVXNhZ2USVQoNZGVsZXRlX21lbW9yeRgmIAEo'
|
||||||
'bGVzc1JlY292ZXJ5EnkKGXBhc3N3b3JkbGVzc19ub3RpZmljYXRpb24YICABKAsyOi5jbGllbn'
|
'CzIuLmNsaWVudF90b19zZXJ2ZXIuQXBwbGljYXRpb25EYXRhLkRlbGV0ZU1lbW9yeUgAUgxkZW'
|
||||||
'RfdG9fc2VydmVyLkFwcGxpY2F0aW9uRGF0YS5QYXNzd29yZGxlc3NOb3RpZmljYXRpb25IAFIY'
|
'xldGVNZW1vcnkScQoXZGlzYWJsZV9tZW1vcmllc19iYWNrdXAYJyABKAsyNy5jbGllbnRfdG9f'
|
||||||
'cGFzc3dvcmRsZXNzTm90aWZpY2F0aW9uGmoKC1RleHRNZXNzYWdlEhcKB3VzZXJfaWQYASABKA'
|
'c2VydmVyLkFwcGxpY2F0aW9uRGF0YS5EaXNhYmxlTWVtb3JpZXNCYWNrdXBIAFIVZGlzYWJsZU'
|
||||||
'NSBnVzZXJJZBISCgRib2R5GAMgASgMUgRib2R5EiAKCXB1c2hfZGF0YRgEIAEoDEgAUghwdXNo'
|
'1lbW9yaWVzQmFja3VwGmoKC1RleHRNZXNzYWdlEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBIS'
|
||||||
'RGF0YYgBAUIMCgpfcHVzaF9kYXRhGi8KEUdldFVzZXJCeVVzZXJuYW1lEhoKCHVzZXJuYW1lGA'
|
'CgRib2R5GAMgASgMUgRib2R5EiAKCXB1c2hfZGF0YRgEIAEoDEgAUghwdXNoRGF0YYgBAUIMCg'
|
||||||
'EgASgJUgh1c2VybmFtZRosCg5DaGFuZ2VVc2VybmFtZRIaCgh1c2VybmFtZRgBIAEoCVIIdXNl'
|
'pfcHVzaF9kYXRhGi8KEUdldFVzZXJCeVVzZXJuYW1lEhoKCHVzZXJuYW1lGAEgASgJUgh1c2Vy'
|
||||||
'cm5hbWUaNQoUVXBkYXRlR29vZ2xlRmNtVG9rZW4SHQoKZ29vZ2xlX2ZjbRgBIAEoCVIJZ29vZ2'
|
'bmFtZRosCg5DaGFuZ2VVc2VybmFtZRIaCgh1c2VybmFtZRgBIAEoCVIIdXNlcm5hbWUaNQoUVX'
|
||||||
'xlRmNtGiYKC0dldFVzZXJCeUlkEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBoTChFHZXRBdmFp'
|
'BkYXRlR29vZ2xlRmNtVG9rZW4SHQoKZ29vZ2xlX2ZjbRgBIAEoCVIJZ29vZ2xlRmNtGiYKC0dl'
|
||||||
'bGFibGVQbGFucxoXChVHZXRBZGRBY2NvdW50c0ludml0ZXMaFQoTR2V0Q3VycmVudFBsYW5Jbm'
|
'dFVzZXJCeUlkEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBoTChFHZXRBdmFpbGFibGVQbGFucx'
|
||||||
'ZvcxovChRSZW1vdmVBZGRpdGlvbmFsVXNlchIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQaLQoS'
|
'oVChNHZXRDdXJyZW50UGxhbkluZm9zGi8KFFJlbW92ZUFkZGl0aW9uYWxVc2VyEhcKB3VzZXJf'
|
||||||
'R2V0UHJla2V5c0J5VXNlcklkEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBoyChdHZXRTaWduZW'
|
'aWQYASABKANSBnVzZXJJZBotChJHZXRQcmVrZXlzQnlVc2VySWQSFwoHdXNlcl9pZBgBIAEoA1'
|
||||||
'RQcmVLZXlCeVVzZXJJZBIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQamwEKElVwZGF0ZVNpZ25l'
|
'IGdXNlcklkGjIKF0dldFNpZ25lZFByZUtleUJ5VXNlcklkEhcKB3VzZXJfaWQYASABKANSBnVz'
|
||||||
'ZFByZUtleRIoChBzaWduZWRfcHJla2V5X2lkGAEgASgDUg5zaWduZWRQcmVrZXlJZBIjCg1zaW'
|
'ZXJJZBqbAQoSVXBkYXRlU2lnbmVkUHJlS2V5EigKEHNpZ25lZF9wcmVrZXlfaWQYASABKANSDn'
|
||||||
'duZWRfcHJla2V5GAIgASgMUgxzaWduZWRQcmVrZXkSNgoXc2lnbmVkX3ByZWtleV9zaWduYXR1'
|
'NpZ25lZFByZWtleUlkEiMKDXNpZ25lZF9wcmVrZXkYAiABKAxSDHNpZ25lZFByZWtleRI2Chdz'
|
||||||
'cmUYAyABKAxSFXNpZ25lZFByZWtleVNpZ25hdHVyZRo1CgxEb3dubG9hZERvbmUSJQoOZG93bm'
|
'aWduZWRfcHJla2V5X3NpZ25hdHVyZRgDIAEoDFIVc2lnbmVkUHJla2V5U2lnbmF0dXJlGjUKDE'
|
||||||
'xvYWRfdG9rZW4YASABKAxSDWRvd25sb2FkVG9rZW4aTgoKUmVwb3J0VXNlchIoChByZXBvcnRl'
|
'Rvd25sb2FkRG9uZRIlCg5kb3dubG9hZF90b2tlbhgBIAEoDFINZG93bmxvYWRUb2tlbhpOCgpS'
|
||||||
'ZF91c2VyX2lkGAEgASgDUg5yZXBvcnRlZFVzZXJJZBIWCgZyZWFzb24YAiABKAlSBnJlYXNvbh'
|
'ZXBvcnRVc2VyEigKEHJlcG9ydGVkX3VzZXJfaWQYASABKANSDnJlcG9ydGVkVXNlcklkEhYKBn'
|
||||||
'pxCgtJUEFQdXJjaGFzZRIdCgpwcm9kdWN0X2lkGAEgASgJUglwcm9kdWN0SWQSFgoGc291cmNl'
|
'JlYXNvbhgCIAEoCVIGcmVhc29uGnEKC0lQQVB1cmNoYXNlEh0KCnByb2R1Y3RfaWQYASABKAlS'
|
||||||
'GAIgASgJUgZzb3VyY2USKwoRdmVyaWZpY2F0aW9uX2RhdGEYAyABKAlSEHZlcmlmaWNhdGlvbk'
|
'CXByb2R1Y3RJZBIWCgZzb3VyY2UYAiABKAlSBnNvdXJjZRIrChF2ZXJpZmljYXRpb25fZGF0YR'
|
||||||
'RhdGEaDwoNSVBBRm9yY2VDaGVjaxoPCg1EZWxldGVBY2NvdW50GiwKEUFkZEFkZGl0aW9uYWxV'
|
'gDIAEoCVIQdmVyaWZpY2F0aW9uRGF0YRoPCg1JUEFGb3JjZUNoZWNrGg8KDURlbGV0ZUFjY291'
|
||||||
'c2VyEhcKB3VzZXJfaWQYASABKANSBnVzZXJJZBowCg1TZXRMb2dpblRva2VuEh8KC2xvZ2luX3'
|
'bnQaLAoRQWRkQWRkaXRpb25hbFVzZXISFwoHdXNlcl9pZBgBIAEoA1IGdXNlcklkGjAKDVNldE'
|
||||||
'Rva2VuGAEgASgMUgpsb2dpblRva2VuGgwKCkRlcHJlY2F0ZWQajgEKHFJlZ2lzdGVyUGFzc3dv'
|
'xvZ2luVG9rZW4SHwoLbG9naW5fdG9rZW4YASABKAxSCmxvZ2luVG9rZW4ajgEKHFJlZ2lzdGVy'
|
||||||
'cmRMZXNzUmVjb3ZlcnkSLgoSZW5jcnlwdGVkU2VydmVyS2V5GAEgASgMUhJlbmNyeXB0ZWRTZX'
|
'UGFzc3dvcmRMZXNzUmVjb3ZlcnkSLgoSZW5jcnlwdGVkU2VydmVyS2V5GAEgASgMUhJlbmNyeX'
|
||||||
'J2ZXJLZXkSKwoOcGluVW5sb2NrVG9rZW4YAiABKAxIAFIOcGluVW5sb2NrVG9rZW6IAQFCEQoP'
|
'B0ZWRTZXJ2ZXJLZXkSKwoOcGluVW5sb2NrVG9rZW4YAiABKAxIAFIOcGluVW5sb2NrVG9rZW6I'
|
||||||
'X3BpblVubG9ja1Rva2VuGnAKGFBhc3N3b3JkbGVzc05vdGlmaWNhdGlvbhInCg9ub3RpZmljYX'
|
'AQFCEQoPX3BpblVubG9ja1Rva2VuGnAKGFBhc3N3b3JkbGVzc05vdGlmaWNhdGlvbhInCg9ub3'
|
||||||
'Rpb25faWQYASABKAlSDm5vdGlmaWNhdGlvbklkEisKEWVuY3J5cHRlZF9tZXNzYWdlGAIgASgM'
|
'RpZmljYXRpb25faWQYASABKAlSDm5vdGlmaWNhdGlvbklkEisKEWVuY3J5cHRlZF9tZXNzYWdl'
|
||||||
'UhBlbmNyeXB0ZWRNZXNzYWdlQhEKD0FwcGxpY2F0aW9uRGF0YQ==');
|
'GAIgASgMUhBlbmNyeXB0ZWRNZXNzYWdlGmsKFVJlcXVlc3RNZW1vcmllc1VwbG9hZBISCgRzaX'
|
||||||
|
'plGAEgASgDUgRzaXplEiMKDW9yaWdpbmFsX2RhdGUYAiABKANSDG9yaWdpbmFsRGF0ZRIZCght'
|
||||||
|
'ZWRpYV9pZBgDIAEoCVIHbWVkaWFJZBoyChVDb25maXJtTWVtb3JpZXNVcGxvYWQSGQoIbWVkaW'
|
||||||
|
'FfaWQYASABKAlSB21lZGlhSWQaSAoPR2V0TWVtb3JpZXNMaXN0Eh8KC29mZnNldF9kYXRlGAEg'
|
||||||
|
'ASgDUgpvZmZzZXREYXRlEhQKBWxpbWl0GAIgASgDUgVsaW1pdBpJCg5HZXRNZW1vcmllc1VybB'
|
||||||
|
'IZCghtZWRpYV9pZBgBIAEoCVIHbWVkaWFJZBIcCgl0aHVtYm5haWwYAiABKAhSCXRodW1ibmFp'
|
||||||
|
'bBoSChBHZXRNZW1vcmllc1VzYWdlGikKDERlbGV0ZU1lbW9yeRIZCghtZWRpYV9pZBgBIAEoCV'
|
||||||
|
'IHbWVkaWFJZBoXChVEaXNhYmxlTWVtb3JpZXNCYWNrdXBCEQoPQXBwbGljYXRpb25EYXRhSgQI'
|
||||||
|
'CRAKSgQICxAMSgQIDRASSgQIExAU');
|
||||||
|
|
||||||
@$core.Deprecated('Use responseDescriptor instead')
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
const Response$json = {
|
const Response$json = {
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,7 @@ class Response_Plan extends $pb.GeneratedMessage {
|
||||||
$fixnum.Int64? yearlyCostsCent,
|
$fixnum.Int64? yearlyCostsCent,
|
||||||
$core.bool? allowedToSendTextMessages,
|
$core.bool? allowedToSendTextMessages,
|
||||||
$core.bool? isAdditionalAccount,
|
$core.bool? isAdditionalAccount,
|
||||||
|
$fixnum.Int64? memoriesSizeLimit,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (planId != null) result.planId = planId;
|
if (planId != null) result.planId = planId;
|
||||||
|
|
@ -447,6 +448,7 @@ class Response_Plan extends $pb.GeneratedMessage {
|
||||||
result.allowedToSendTextMessages = allowedToSendTextMessages;
|
result.allowedToSendTextMessages = allowedToSendTextMessages;
|
||||||
if (isAdditionalAccount != null)
|
if (isAdditionalAccount != null)
|
||||||
result.isAdditionalAccount = isAdditionalAccount;
|
result.isAdditionalAccount = isAdditionalAccount;
|
||||||
|
if (memoriesSizeLimit != null) result.memoriesSizeLimit = memoriesSizeLimit;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -473,6 +475,7 @@ class Response_Plan extends $pb.GeneratedMessage {
|
||||||
..aInt64(8, _omitFieldNames ? '' : 'yearlyCostsCent')
|
..aInt64(8, _omitFieldNames ? '' : 'yearlyCostsCent')
|
||||||
..aOB(9, _omitFieldNames ? '' : 'allowedToSendTextMessages')
|
..aOB(9, _omitFieldNames ? '' : 'allowedToSendTextMessages')
|
||||||
..aOB(10, _omitFieldNames ? '' : 'isAdditionalAccount')
|
..aOB(10, _omitFieldNames ? '' : 'isAdditionalAccount')
|
||||||
|
..aInt64(11, _omitFieldNames ? '' : 'memoriesSizeLimit')
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
|
@ -575,6 +578,15 @@ class Response_Plan extends $pb.GeneratedMessage {
|
||||||
$core.bool hasIsAdditionalAccount() => $_has(8);
|
$core.bool hasIsAdditionalAccount() => $_has(8);
|
||||||
@$pb.TagNumber(10)
|
@$pb.TagNumber(10)
|
||||||
void clearIsAdditionalAccount() => $_clearField(10);
|
void clearIsAdditionalAccount() => $_clearField(10);
|
||||||
|
|
||||||
|
@$pb.TagNumber(11)
|
||||||
|
$fixnum.Int64 get memoriesSizeLimit => $_getI64(9);
|
||||||
|
@$pb.TagNumber(11)
|
||||||
|
set memoriesSizeLimit($fixnum.Int64 value) => $_setInt64(9, value);
|
||||||
|
@$pb.TagNumber(11)
|
||||||
|
$core.bool hasMemoriesSizeLimit() => $_has(9);
|
||||||
|
@$pb.TagNumber(11)
|
||||||
|
void clearMemoriesSizeLimit() => $_clearField(11);
|
||||||
}
|
}
|
||||||
|
|
||||||
class Response_Plans extends $pb.GeneratedMessage {
|
class Response_Plans extends $pb.GeneratedMessage {
|
||||||
|
|
@ -1635,6 +1647,426 @@ class Response_PasswordlessNotificationMessages extends $pb.GeneratedMessage {
|
||||||
$_getList(0);
|
$_getList(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Response_PresignedPost extends $pb.GeneratedMessage {
|
||||||
|
factory Response_PresignedPost({
|
||||||
|
$core.String? url,
|
||||||
|
$core.Iterable<$core.MapEntry<$core.String, $core.String>>? fields,
|
||||||
|
}) {
|
||||||
|
final result = create();
|
||||||
|
if (url != null) result.url = url;
|
||||||
|
if (fields != null) result.fields.addEntries(fields);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response_PresignedPost._();
|
||||||
|
|
||||||
|
factory Response_PresignedPost.fromBuffer($core.List<$core.int> data,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromBuffer(data, registry);
|
||||||
|
factory Response_PresignedPost.fromJson($core.String json,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromJson(json, registry);
|
||||||
|
|
||||||
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
_omitMessageNames ? '' : 'Response.PresignedPost',
|
||||||
|
package:
|
||||||
|
const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'),
|
||||||
|
createEmptyInstance: create)
|
||||||
|
..aOS(1, _omitFieldNames ? '' : 'url')
|
||||||
|
..m<$core.String, $core.String>(2, _omitFieldNames ? '' : 'fields',
|
||||||
|
entryClassName: 'Response.PresignedPost.FieldsEntry',
|
||||||
|
keyFieldType: $pb.PbFieldType.OS,
|
||||||
|
valueFieldType: $pb.PbFieldType.OS,
|
||||||
|
packageName: const $pb.PackageName('server_to_client'))
|
||||||
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_PresignedPost clone() => deepCopy();
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_PresignedPost copyWith(
|
||||||
|
void Function(Response_PresignedPost) updates) =>
|
||||||
|
super.copyWith((message) => updates(message as Response_PresignedPost))
|
||||||
|
as Response_PresignedPost;
|
||||||
|
|
||||||
|
@$core.override
|
||||||
|
$pb.BuilderInfo get info_ => _i;
|
||||||
|
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_PresignedPost create() => Response_PresignedPost._();
|
||||||
|
@$core.override
|
||||||
|
Response_PresignedPost createEmptyInstance() => create();
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_PresignedPost getDefault() => _defaultInstance ??=
|
||||||
|
$pb.GeneratedMessage.$_defaultFor<Response_PresignedPost>(create);
|
||||||
|
static Response_PresignedPost? _defaultInstance;
|
||||||
|
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.String get url => $_getSZ(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
set url($core.String value) => $_setString(0, value);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.bool hasUrl() => $_has(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
void clearUrl() => $_clearField(1);
|
||||||
|
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$pb.PbMap<$core.String, $core.String> get fields => $_getMap(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Response_MemoriesUploadUrls extends $pb.GeneratedMessage {
|
||||||
|
factory Response_MemoriesUploadUrls({
|
||||||
|
$core.String? mediaId,
|
||||||
|
Response_PresignedPost? thumbnailUpload,
|
||||||
|
Response_PresignedPost? fullUpload,
|
||||||
|
}) {
|
||||||
|
final result = create();
|
||||||
|
if (mediaId != null) result.mediaId = mediaId;
|
||||||
|
if (thumbnailUpload != null) result.thumbnailUpload = thumbnailUpload;
|
||||||
|
if (fullUpload != null) result.fullUpload = fullUpload;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response_MemoriesUploadUrls._();
|
||||||
|
|
||||||
|
factory Response_MemoriesUploadUrls.fromBuffer($core.List<$core.int> data,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromBuffer(data, registry);
|
||||||
|
factory Response_MemoriesUploadUrls.fromJson($core.String json,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromJson(json, registry);
|
||||||
|
|
||||||
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
_omitMessageNames ? '' : 'Response.MemoriesUploadUrls',
|
||||||
|
package:
|
||||||
|
const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'),
|
||||||
|
createEmptyInstance: create)
|
||||||
|
..aOS(1, _omitFieldNames ? '' : 'mediaId')
|
||||||
|
..aOM<Response_PresignedPost>(2, _omitFieldNames ? '' : 'thumbnailUpload',
|
||||||
|
subBuilder: Response_PresignedPost.create)
|
||||||
|
..aOM<Response_PresignedPost>(3, _omitFieldNames ? '' : 'fullUpload',
|
||||||
|
subBuilder: Response_PresignedPost.create)
|
||||||
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MemoriesUploadUrls clone() => deepCopy();
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MemoriesUploadUrls copyWith(
|
||||||
|
void Function(Response_MemoriesUploadUrls) updates) =>
|
||||||
|
super.copyWith(
|
||||||
|
(message) => updates(message as Response_MemoriesUploadUrls))
|
||||||
|
as Response_MemoriesUploadUrls;
|
||||||
|
|
||||||
|
@$core.override
|
||||||
|
$pb.BuilderInfo get info_ => _i;
|
||||||
|
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MemoriesUploadUrls create() =>
|
||||||
|
Response_MemoriesUploadUrls._();
|
||||||
|
@$core.override
|
||||||
|
Response_MemoriesUploadUrls createEmptyInstance() => create();
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MemoriesUploadUrls getDefault() => _defaultInstance ??=
|
||||||
|
$pb.GeneratedMessage.$_defaultFor<Response_MemoriesUploadUrls>(create);
|
||||||
|
static Response_MemoriesUploadUrls? _defaultInstance;
|
||||||
|
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.String get mediaId => $_getSZ(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
set mediaId($core.String value) => $_setString(0, value);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.bool hasMediaId() => $_has(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
void clearMediaId() => $_clearField(1);
|
||||||
|
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
Response_PresignedPost get thumbnailUpload => $_getN(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
set thumbnailUpload(Response_PresignedPost value) => $_setField(2, value);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$core.bool hasThumbnailUpload() => $_has(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
void clearThumbnailUpload() => $_clearField(2);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
Response_PresignedPost ensureThumbnailUpload() => $_ensure(1);
|
||||||
|
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
Response_PresignedPost get fullUpload => $_getN(2);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
set fullUpload(Response_PresignedPost value) => $_setField(3, value);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
$core.bool hasFullUpload() => $_has(2);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
void clearFullUpload() => $_clearField(3);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
Response_PresignedPost ensureFullUpload() => $_ensure(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Response_MediaItem extends $pb.GeneratedMessage {
|
||||||
|
factory Response_MediaItem({
|
||||||
|
$core.String? mediaId,
|
||||||
|
$fixnum.Int64? originalDate,
|
||||||
|
$core.String? thumbnailDownloadUrl,
|
||||||
|
}) {
|
||||||
|
final result = create();
|
||||||
|
if (mediaId != null) result.mediaId = mediaId;
|
||||||
|
if (originalDate != null) result.originalDate = originalDate;
|
||||||
|
if (thumbnailDownloadUrl != null)
|
||||||
|
result.thumbnailDownloadUrl = thumbnailDownloadUrl;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response_MediaItem._();
|
||||||
|
|
||||||
|
factory Response_MediaItem.fromBuffer($core.List<$core.int> data,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromBuffer(data, registry);
|
||||||
|
factory Response_MediaItem.fromJson($core.String json,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromJson(json, registry);
|
||||||
|
|
||||||
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
_omitMessageNames ? '' : 'Response.MediaItem',
|
||||||
|
package:
|
||||||
|
const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'),
|
||||||
|
createEmptyInstance: create)
|
||||||
|
..aOS(1, _omitFieldNames ? '' : 'mediaId')
|
||||||
|
..aInt64(2, _omitFieldNames ? '' : 'originalDate')
|
||||||
|
..aOS(3, _omitFieldNames ? '' : 'thumbnailDownloadUrl')
|
||||||
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MediaItem clone() => deepCopy();
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MediaItem copyWith(void Function(Response_MediaItem) updates) =>
|
||||||
|
super.copyWith((message) => updates(message as Response_MediaItem))
|
||||||
|
as Response_MediaItem;
|
||||||
|
|
||||||
|
@$core.override
|
||||||
|
$pb.BuilderInfo get info_ => _i;
|
||||||
|
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MediaItem create() => Response_MediaItem._();
|
||||||
|
@$core.override
|
||||||
|
Response_MediaItem createEmptyInstance() => create();
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MediaItem getDefault() => _defaultInstance ??=
|
||||||
|
$pb.GeneratedMessage.$_defaultFor<Response_MediaItem>(create);
|
||||||
|
static Response_MediaItem? _defaultInstance;
|
||||||
|
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.String get mediaId => $_getSZ(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
set mediaId($core.String value) => $_setString(0, value);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.bool hasMediaId() => $_has(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
void clearMediaId() => $_clearField(1);
|
||||||
|
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$fixnum.Int64 get originalDate => $_getI64(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
set originalDate($fixnum.Int64 value) => $_setInt64(1, value);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$core.bool hasOriginalDate() => $_has(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
void clearOriginalDate() => $_clearField(2);
|
||||||
|
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
$core.String get thumbnailDownloadUrl => $_getSZ(2);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
set thumbnailDownloadUrl($core.String value) => $_setString(2, value);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
$core.bool hasThumbnailDownloadUrl() => $_has(2);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
void clearThumbnailDownloadUrl() => $_clearField(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Response_MemoriesList extends $pb.GeneratedMessage {
|
||||||
|
factory Response_MemoriesList({
|
||||||
|
$core.Iterable<Response_MediaItem>? items,
|
||||||
|
}) {
|
||||||
|
final result = create();
|
||||||
|
if (items != null) result.items.addAll(items);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response_MemoriesList._();
|
||||||
|
|
||||||
|
factory Response_MemoriesList.fromBuffer($core.List<$core.int> data,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromBuffer(data, registry);
|
||||||
|
factory Response_MemoriesList.fromJson($core.String json,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromJson(json, registry);
|
||||||
|
|
||||||
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
_omitMessageNames ? '' : 'Response.MemoriesList',
|
||||||
|
package:
|
||||||
|
const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'),
|
||||||
|
createEmptyInstance: create)
|
||||||
|
..pPM<Response_MediaItem>(1, _omitFieldNames ? '' : 'items',
|
||||||
|
subBuilder: Response_MediaItem.create)
|
||||||
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MemoriesList clone() => deepCopy();
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MemoriesList copyWith(
|
||||||
|
void Function(Response_MemoriesList) updates) =>
|
||||||
|
super.copyWith((message) => updates(message as Response_MemoriesList))
|
||||||
|
as Response_MemoriesList;
|
||||||
|
|
||||||
|
@$core.override
|
||||||
|
$pb.BuilderInfo get info_ => _i;
|
||||||
|
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MemoriesList create() => Response_MemoriesList._();
|
||||||
|
@$core.override
|
||||||
|
Response_MemoriesList createEmptyInstance() => create();
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MemoriesList getDefault() => _defaultInstance ??=
|
||||||
|
$pb.GeneratedMessage.$_defaultFor<Response_MemoriesList>(create);
|
||||||
|
static Response_MemoriesList? _defaultInstance;
|
||||||
|
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$pb.PbList<Response_MediaItem> get items => $_getList(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Response_MemoriesUrl extends $pb.GeneratedMessage {
|
||||||
|
factory Response_MemoriesUrl({
|
||||||
|
$core.String? fullDownloadUrl,
|
||||||
|
}) {
|
||||||
|
final result = create();
|
||||||
|
if (fullDownloadUrl != null) result.fullDownloadUrl = fullDownloadUrl;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response_MemoriesUrl._();
|
||||||
|
|
||||||
|
factory Response_MemoriesUrl.fromBuffer($core.List<$core.int> data,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromBuffer(data, registry);
|
||||||
|
factory Response_MemoriesUrl.fromJson($core.String json,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromJson(json, registry);
|
||||||
|
|
||||||
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
_omitMessageNames ? '' : 'Response.MemoriesUrl',
|
||||||
|
package:
|
||||||
|
const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'),
|
||||||
|
createEmptyInstance: create)
|
||||||
|
..aOS(1, _omitFieldNames ? '' : 'fullDownloadUrl')
|
||||||
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MemoriesUrl clone() => deepCopy();
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MemoriesUrl copyWith(void Function(Response_MemoriesUrl) updates) =>
|
||||||
|
super.copyWith((message) => updates(message as Response_MemoriesUrl))
|
||||||
|
as Response_MemoriesUrl;
|
||||||
|
|
||||||
|
@$core.override
|
||||||
|
$pb.BuilderInfo get info_ => _i;
|
||||||
|
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MemoriesUrl create() => Response_MemoriesUrl._();
|
||||||
|
@$core.override
|
||||||
|
Response_MemoriesUrl createEmptyInstance() => create();
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MemoriesUrl getDefault() => _defaultInstance ??=
|
||||||
|
$pb.GeneratedMessage.$_defaultFor<Response_MemoriesUrl>(create);
|
||||||
|
static Response_MemoriesUrl? _defaultInstance;
|
||||||
|
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.String get fullDownloadUrl => $_getSZ(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
set fullDownloadUrl($core.String value) => $_setString(0, value);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.bool hasFullDownloadUrl() => $_has(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
void clearFullDownloadUrl() => $_clearField(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
class Response_MemoriesUsage extends $pb.GeneratedMessage {
|
||||||
|
factory Response_MemoriesUsage({
|
||||||
|
$fixnum.Int64? maxBytes,
|
||||||
|
$fixnum.Int64? currentBytes,
|
||||||
|
$fixnum.Int64? count,
|
||||||
|
}) {
|
||||||
|
final result = create();
|
||||||
|
if (maxBytes != null) result.maxBytes = maxBytes;
|
||||||
|
if (currentBytes != null) result.currentBytes = currentBytes;
|
||||||
|
if (count != null) result.count = count;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Response_MemoriesUsage._();
|
||||||
|
|
||||||
|
factory Response_MemoriesUsage.fromBuffer($core.List<$core.int> data,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromBuffer(data, registry);
|
||||||
|
factory Response_MemoriesUsage.fromJson($core.String json,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromJson(json, registry);
|
||||||
|
|
||||||
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
_omitMessageNames ? '' : 'Response.MemoriesUsage',
|
||||||
|
package:
|
||||||
|
const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'),
|
||||||
|
createEmptyInstance: create)
|
||||||
|
..aInt64(1, _omitFieldNames ? '' : 'maxBytes')
|
||||||
|
..aInt64(2, _omitFieldNames ? '' : 'currentBytes')
|
||||||
|
..aInt64(3, _omitFieldNames ? '' : 'count')
|
||||||
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MemoriesUsage clone() => deepCopy();
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
Response_MemoriesUsage copyWith(
|
||||||
|
void Function(Response_MemoriesUsage) updates) =>
|
||||||
|
super.copyWith((message) => updates(message as Response_MemoriesUsage))
|
||||||
|
as Response_MemoriesUsage;
|
||||||
|
|
||||||
|
@$core.override
|
||||||
|
$pb.BuilderInfo get info_ => _i;
|
||||||
|
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MemoriesUsage create() => Response_MemoriesUsage._();
|
||||||
|
@$core.override
|
||||||
|
Response_MemoriesUsage createEmptyInstance() => create();
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static Response_MemoriesUsage getDefault() => _defaultInstance ??=
|
||||||
|
$pb.GeneratedMessage.$_defaultFor<Response_MemoriesUsage>(create);
|
||||||
|
static Response_MemoriesUsage? _defaultInstance;
|
||||||
|
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$fixnum.Int64 get maxBytes => $_getI64(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
set maxBytes($fixnum.Int64 value) => $_setInt64(0, value);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.bool hasMaxBytes() => $_has(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
void clearMaxBytes() => $_clearField(1);
|
||||||
|
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$fixnum.Int64 get currentBytes => $_getI64(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
set currentBytes($fixnum.Int64 value) => $_setInt64(1, value);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$core.bool hasCurrentBytes() => $_has(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
void clearCurrentBytes() => $_clearField(2);
|
||||||
|
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
$fixnum.Int64 get count => $_getI64(2);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
set count($fixnum.Int64 value) => $_setInt64(2, value);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
$core.bool hasCount() => $_has(2);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
void clearCount() => $_clearField(3);
|
||||||
|
}
|
||||||
|
|
||||||
enum Response_Ok_Ok {
|
enum Response_Ok_Ok {
|
||||||
none,
|
none,
|
||||||
userid,
|
userid,
|
||||||
|
|
@ -1653,6 +2085,10 @@ enum Response_Ok_Ok {
|
||||||
proofOfWork,
|
proofOfWork,
|
||||||
passwordlessRecoveryServerKey,
|
passwordlessRecoveryServerKey,
|
||||||
passwordlessNotificationMessages,
|
passwordlessNotificationMessages,
|
||||||
|
memoriesUploadUrls,
|
||||||
|
memoriesList,
|
||||||
|
memoriesUrl,
|
||||||
|
memoriesUsage,
|
||||||
notSet
|
notSet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1675,6 +2111,10 @@ class Response_Ok extends $pb.GeneratedMessage {
|
||||||
Response_ProofOfWork? proofOfWork,
|
Response_ProofOfWork? proofOfWork,
|
||||||
$core.List<$core.int>? passwordlessRecoveryServerKey,
|
$core.List<$core.int>? passwordlessRecoveryServerKey,
|
||||||
Response_PasswordlessNotificationMessages? passwordlessNotificationMessages,
|
Response_PasswordlessNotificationMessages? passwordlessNotificationMessages,
|
||||||
|
Response_MemoriesUploadUrls? memoriesUploadUrls,
|
||||||
|
Response_MemoriesList? memoriesList,
|
||||||
|
Response_MemoriesUrl? memoriesUrl,
|
||||||
|
Response_MemoriesUsage? memoriesUsage,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (none != null) result.none = none;
|
if (none != null) result.none = none;
|
||||||
|
|
@ -1698,6 +2138,11 @@ class Response_Ok extends $pb.GeneratedMessage {
|
||||||
if (passwordlessNotificationMessages != null)
|
if (passwordlessNotificationMessages != null)
|
||||||
result.passwordlessNotificationMessages =
|
result.passwordlessNotificationMessages =
|
||||||
passwordlessNotificationMessages;
|
passwordlessNotificationMessages;
|
||||||
|
if (memoriesUploadUrls != null)
|
||||||
|
result.memoriesUploadUrls = memoriesUploadUrls;
|
||||||
|
if (memoriesList != null) result.memoriesList = memoriesList;
|
||||||
|
if (memoriesUrl != null) result.memoriesUrl = memoriesUrl;
|
||||||
|
if (memoriesUsage != null) result.memoriesUsage = memoriesUsage;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1728,6 +2173,10 @@ class Response_Ok extends $pb.GeneratedMessage {
|
||||||
15: Response_Ok_Ok.proofOfWork,
|
15: Response_Ok_Ok.proofOfWork,
|
||||||
16: Response_Ok_Ok.passwordlessRecoveryServerKey,
|
16: Response_Ok_Ok.passwordlessRecoveryServerKey,
|
||||||
17: Response_Ok_Ok.passwordlessNotificationMessages,
|
17: Response_Ok_Ok.passwordlessNotificationMessages,
|
||||||
|
18: Response_Ok_Ok.memoriesUploadUrls,
|
||||||
|
19: Response_Ok_Ok.memoriesList,
|
||||||
|
20: Response_Ok_Ok.memoriesUrl,
|
||||||
|
21: Response_Ok_Ok.memoriesUsage,
|
||||||
0: Response_Ok_Ok.notSet
|
0: Response_Ok_Ok.notSet
|
||||||
};
|
};
|
||||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
|
@ -1735,7 +2184,29 @@ class Response_Ok extends $pb.GeneratedMessage {
|
||||||
package:
|
package:
|
||||||
const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'),
|
const $pb.PackageName(_omitMessageNames ? '' : 'server_to_client'),
|
||||||
createEmptyInstance: create)
|
createEmptyInstance: create)
|
||||||
..oo(0, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17])
|
..oo(0, [
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
5,
|
||||||
|
6,
|
||||||
|
7,
|
||||||
|
8,
|
||||||
|
9,
|
||||||
|
10,
|
||||||
|
11,
|
||||||
|
12,
|
||||||
|
13,
|
||||||
|
14,
|
||||||
|
15,
|
||||||
|
16,
|
||||||
|
17,
|
||||||
|
18,
|
||||||
|
19,
|
||||||
|
20,
|
||||||
|
21
|
||||||
|
])
|
||||||
..aOB(1, _omitFieldNames ? '' : 'None', protoName: 'None')
|
..aOB(1, _omitFieldNames ? '' : 'None', protoName: 'None')
|
||||||
..aInt64(2, _omitFieldNames ? '' : 'userid')
|
..aInt64(2, _omitFieldNames ? '' : 'userid')
|
||||||
..a<$core.List<$core.int>>(
|
..a<$core.List<$core.int>>(
|
||||||
|
|
@ -1772,6 +2243,15 @@ class Response_Ok extends $pb.GeneratedMessage {
|
||||||
..aOM<Response_PasswordlessNotificationMessages>(
|
..aOM<Response_PasswordlessNotificationMessages>(
|
||||||
17, _omitFieldNames ? '' : 'passwordlessNotificationMessages',
|
17, _omitFieldNames ? '' : 'passwordlessNotificationMessages',
|
||||||
subBuilder: Response_PasswordlessNotificationMessages.create)
|
subBuilder: Response_PasswordlessNotificationMessages.create)
|
||||||
|
..aOM<Response_MemoriesUploadUrls>(
|
||||||
|
18, _omitFieldNames ? '' : 'memoriesUploadUrls',
|
||||||
|
subBuilder: Response_MemoriesUploadUrls.create)
|
||||||
|
..aOM<Response_MemoriesList>(19, _omitFieldNames ? '' : 'memoriesList',
|
||||||
|
subBuilder: Response_MemoriesList.create)
|
||||||
|
..aOM<Response_MemoriesUrl>(20, _omitFieldNames ? '' : 'memoriesUrl',
|
||||||
|
subBuilder: Response_MemoriesUrl.create)
|
||||||
|
..aOM<Response_MemoriesUsage>(21, _omitFieldNames ? '' : 'memoriesUsage',
|
||||||
|
subBuilder: Response_MemoriesUsage.create)
|
||||||
..hasRequiredFields = false;
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
|
@ -1810,6 +2290,10 @@ class Response_Ok extends $pb.GeneratedMessage {
|
||||||
@$pb.TagNumber(15)
|
@$pb.TagNumber(15)
|
||||||
@$pb.TagNumber(16)
|
@$pb.TagNumber(16)
|
||||||
@$pb.TagNumber(17)
|
@$pb.TagNumber(17)
|
||||||
|
@$pb.TagNumber(18)
|
||||||
|
@$pb.TagNumber(19)
|
||||||
|
@$pb.TagNumber(20)
|
||||||
|
@$pb.TagNumber(21)
|
||||||
Response_Ok_Ok whichOk() => _Response_Ok_OkByTag[$_whichOneof(0)]!;
|
Response_Ok_Ok whichOk() => _Response_Ok_OkByTag[$_whichOneof(0)]!;
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
|
|
@ -1828,6 +2312,10 @@ class Response_Ok extends $pb.GeneratedMessage {
|
||||||
@$pb.TagNumber(15)
|
@$pb.TagNumber(15)
|
||||||
@$pb.TagNumber(16)
|
@$pb.TagNumber(16)
|
||||||
@$pb.TagNumber(17)
|
@$pb.TagNumber(17)
|
||||||
|
@$pb.TagNumber(18)
|
||||||
|
@$pb.TagNumber(19)
|
||||||
|
@$pb.TagNumber(20)
|
||||||
|
@$pb.TagNumber(21)
|
||||||
void clearOk() => $_clearField($_whichOneof(0));
|
void clearOk() => $_clearField($_whichOneof(0));
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
|
|
@ -2012,6 +2500,51 @@ class Response_Ok extends $pb.GeneratedMessage {
|
||||||
@$pb.TagNumber(17)
|
@$pb.TagNumber(17)
|
||||||
Response_PasswordlessNotificationMessages
|
Response_PasswordlessNotificationMessages
|
||||||
ensurePasswordlessNotificationMessages() => $_ensure(16);
|
ensurePasswordlessNotificationMessages() => $_ensure(16);
|
||||||
|
|
||||||
|
@$pb.TagNumber(18)
|
||||||
|
Response_MemoriesUploadUrls get memoriesUploadUrls => $_getN(17);
|
||||||
|
@$pb.TagNumber(18)
|
||||||
|
set memoriesUploadUrls(Response_MemoriesUploadUrls value) =>
|
||||||
|
$_setField(18, value);
|
||||||
|
@$pb.TagNumber(18)
|
||||||
|
$core.bool hasMemoriesUploadUrls() => $_has(17);
|
||||||
|
@$pb.TagNumber(18)
|
||||||
|
void clearMemoriesUploadUrls() => $_clearField(18);
|
||||||
|
@$pb.TagNumber(18)
|
||||||
|
Response_MemoriesUploadUrls ensureMemoriesUploadUrls() => $_ensure(17);
|
||||||
|
|
||||||
|
@$pb.TagNumber(19)
|
||||||
|
Response_MemoriesList get memoriesList => $_getN(18);
|
||||||
|
@$pb.TagNumber(19)
|
||||||
|
set memoriesList(Response_MemoriesList value) => $_setField(19, value);
|
||||||
|
@$pb.TagNumber(19)
|
||||||
|
$core.bool hasMemoriesList() => $_has(18);
|
||||||
|
@$pb.TagNumber(19)
|
||||||
|
void clearMemoriesList() => $_clearField(19);
|
||||||
|
@$pb.TagNumber(19)
|
||||||
|
Response_MemoriesList ensureMemoriesList() => $_ensure(18);
|
||||||
|
|
||||||
|
@$pb.TagNumber(20)
|
||||||
|
Response_MemoriesUrl get memoriesUrl => $_getN(19);
|
||||||
|
@$pb.TagNumber(20)
|
||||||
|
set memoriesUrl(Response_MemoriesUrl value) => $_setField(20, value);
|
||||||
|
@$pb.TagNumber(20)
|
||||||
|
$core.bool hasMemoriesUrl() => $_has(19);
|
||||||
|
@$pb.TagNumber(20)
|
||||||
|
void clearMemoriesUrl() => $_clearField(20);
|
||||||
|
@$pb.TagNumber(20)
|
||||||
|
Response_MemoriesUrl ensureMemoriesUrl() => $_ensure(19);
|
||||||
|
|
||||||
|
@$pb.TagNumber(21)
|
||||||
|
Response_MemoriesUsage get memoriesUsage => $_getN(20);
|
||||||
|
@$pb.TagNumber(21)
|
||||||
|
set memoriesUsage(Response_MemoriesUsage value) => $_setField(21, value);
|
||||||
|
@$pb.TagNumber(21)
|
||||||
|
$core.bool hasMemoriesUsage() => $_has(20);
|
||||||
|
@$pb.TagNumber(21)
|
||||||
|
void clearMemoriesUsage() => $_clearField(21);
|
||||||
|
@$pb.TagNumber(21)
|
||||||
|
Response_MemoriesUsage ensureMemoriesUsage() => $_ensure(20);
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Response_Response { ok, error, notSet }
|
enum Response_Response { ok, error, notSet }
|
||||||
|
|
|
||||||
|
|
@ -178,6 +178,12 @@ const Response$json = {
|
||||||
Response_ProofOfWork$json,
|
Response_ProofOfWork$json,
|
||||||
Response_PasswordlessNotificationMessage$json,
|
Response_PasswordlessNotificationMessage$json,
|
||||||
Response_PasswordlessNotificationMessages$json,
|
Response_PasswordlessNotificationMessages$json,
|
||||||
|
Response_PresignedPost$json,
|
||||||
|
Response_MemoriesUploadUrls$json,
|
||||||
|
Response_MediaItem$json,
|
||||||
|
Response_MemoriesList$json,
|
||||||
|
Response_MemoriesUrl$json,
|
||||||
|
Response_MemoriesUsage$json,
|
||||||
Response_Ok$json
|
Response_Ok$json
|
||||||
],
|
],
|
||||||
'8': [
|
'8': [
|
||||||
|
|
@ -242,6 +248,13 @@ const Response_Plan$json = {
|
||||||
'5': 8,
|
'5': 8,
|
||||||
'10': 'isAdditionalAccount'
|
'10': 'isAdditionalAccount'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'1': 'memories_size_limit',
|
||||||
|
'3': 11,
|
||||||
|
'4': 1,
|
||||||
|
'5': 3,
|
||||||
|
'10': 'memoriesSizeLimit'
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -541,6 +554,106 @@ const Response_PasswordlessNotificationMessages$json = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
|
const Response_PresignedPost$json = {
|
||||||
|
'1': 'PresignedPost',
|
||||||
|
'2': [
|
||||||
|
{'1': 'url', '3': 1, '4': 1, '5': 9, '10': 'url'},
|
||||||
|
{
|
||||||
|
'1': 'fields',
|
||||||
|
'3': 2,
|
||||||
|
'4': 3,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.server_to_client.Response.PresignedPost.FieldsEntry',
|
||||||
|
'10': 'fields'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'3': [Response_PresignedPost_FieldsEntry$json],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
|
const Response_PresignedPost_FieldsEntry$json = {
|
||||||
|
'1': 'FieldsEntry',
|
||||||
|
'2': [
|
||||||
|
{'1': 'key', '3': 1, '4': 1, '5': 9, '10': 'key'},
|
||||||
|
{'1': 'value', '3': 2, '4': 1, '5': 9, '10': 'value'},
|
||||||
|
],
|
||||||
|
'7': {'7': true},
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
|
const Response_MemoriesUploadUrls$json = {
|
||||||
|
'1': 'MemoriesUploadUrls',
|
||||||
|
'2': [
|
||||||
|
{'1': 'media_id', '3': 1, '4': 1, '5': 9, '10': 'mediaId'},
|
||||||
|
{
|
||||||
|
'1': 'thumbnail_upload',
|
||||||
|
'3': 2,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.server_to_client.Response.PresignedPost',
|
||||||
|
'10': 'thumbnailUpload'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'full_upload',
|
||||||
|
'3': 3,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.server_to_client.Response.PresignedPost',
|
||||||
|
'10': 'fullUpload'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
|
const Response_MediaItem$json = {
|
||||||
|
'1': 'MediaItem',
|
||||||
|
'2': [
|
||||||
|
{'1': 'media_id', '3': 1, '4': 1, '5': 9, '10': 'mediaId'},
|
||||||
|
{'1': 'original_date', '3': 2, '4': 1, '5': 3, '10': 'originalDate'},
|
||||||
|
{
|
||||||
|
'1': 'thumbnail_download_url',
|
||||||
|
'3': 3,
|
||||||
|
'4': 1,
|
||||||
|
'5': 9,
|
||||||
|
'10': 'thumbnailDownloadUrl'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
|
const Response_MemoriesList$json = {
|
||||||
|
'1': 'MemoriesList',
|
||||||
|
'2': [
|
||||||
|
{
|
||||||
|
'1': 'items',
|
||||||
|
'3': 1,
|
||||||
|
'4': 3,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.server_to_client.Response.MediaItem',
|
||||||
|
'10': 'items'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
|
const Response_MemoriesUrl$json = {
|
||||||
|
'1': 'MemoriesUrl',
|
||||||
|
'2': [
|
||||||
|
{'1': 'full_download_url', '3': 1, '4': 1, '5': 9, '10': 'fullDownloadUrl'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
|
const Response_MemoriesUsage$json = {
|
||||||
|
'1': 'MemoriesUsage',
|
||||||
|
'2': [
|
||||||
|
{'1': 'max_bytes', '3': 1, '4': 1, '5': 3, '10': 'maxBytes'},
|
||||||
|
{'1': 'current_bytes', '3': 2, '4': 1, '5': 3, '10': 'currentBytes'},
|
||||||
|
{'1': 'count', '3': 3, '4': 1, '5': 3, '10': 'count'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
@$core.Deprecated('Use responseDescriptor instead')
|
@$core.Deprecated('Use responseDescriptor instead')
|
||||||
const Response_Ok$json = {
|
const Response_Ok$json = {
|
||||||
'1': 'Ok',
|
'1': 'Ok',
|
||||||
|
|
@ -672,6 +785,42 @@ const Response_Ok$json = {
|
||||||
'9': 0,
|
'9': 0,
|
||||||
'10': 'passwordlessNotificationMessages'
|
'10': 'passwordlessNotificationMessages'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'1': 'memories_upload_urls',
|
||||||
|
'3': 18,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.server_to_client.Response.MemoriesUploadUrls',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'memoriesUploadUrls'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'memories_list',
|
||||||
|
'3': 19,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.server_to_client.Response.MemoriesList',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'memoriesList'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'memories_url',
|
||||||
|
'3': 20,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.server_to_client.Response.MemoriesUrl',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'memoriesUrl'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'1': 'memories_usage',
|
||||||
|
'3': 21,
|
||||||
|
'4': 1,
|
||||||
|
'5': 11,
|
||||||
|
'6': '.server_to_client.Response.MemoriesUsage',
|
||||||
|
'9': 0,
|
||||||
|
'10': 'memoriesUsage'
|
||||||
|
},
|
||||||
],
|
],
|
||||||
'8': [
|
'8': [
|
||||||
{'1': 'Ok'},
|
{'1': 'Ok'},
|
||||||
|
|
@ -682,7 +831,7 @@ const Response_Ok$json = {
|
||||||
final $typed_data.Uint8List responseDescriptor = $convert.base64Decode(
|
final $typed_data.Uint8List responseDescriptor = $convert.base64Decode(
|
||||||
'CghSZXNwb25zZRIvCgJvaxgBIAEoCzIdLnNlcnZlcl90b19jbGllbnQuUmVzcG9uc2UuT2tIAF'
|
'CghSZXNwb25zZRIvCgJvaxgBIAEoCzIdLnNlcnZlcl90b19jbGllbnQuUmVzcG9uc2UuT2tIAF'
|
||||||
'ICb2sSKAoFZXJyb3IYAiABKA4yEC5lcnJvci5FcnJvckNvZGVIAFIFZXJyb3IaIwoNQXV0aGVu'
|
'ICb2sSKAoFZXJyb3IYAiABKA4yEC5lcnJvci5FcnJvckNvZGVIAFIFZXJyb3IaIwoNQXV0aGVu'
|
||||||
'dGljYXRlZBISCgRwbGFuGAEgASgJUgRwbGFuGuQDCgRQbGFuEhcKB3BsYW5faWQYASABKAlSBn'
|
'dGljYXRlZBISCgRwbGFuGAEgASgJUgRwbGFuGpQECgRQbGFuEhcKB3BsYW5faWQYASABKAlSBn'
|
||||||
'BsYW5JZBIqChF1cGxvYWRfc2l6ZV9saW1pdBgCIAEoA1IPdXBsb2FkU2l6ZUxpbWl0EjcKGGRh'
|
'BsYW5JZBIqChF1cGxvYWRfc2l6ZV9saW1pdBgCIAEoA1IPdXBsb2FkU2l6ZUxpbWl0EjcKGGRh'
|
||||||
'aWx5X21lZGlhX3VwbG9hZF9saW1pdBgDIAEoA1IVZGFpbHlNZWRpYVVwbG9hZExpbWl0ElQKKG'
|
'aWx5X21lZGlhX3VwbG9hZF9saW1pdBgDIAEoA1IVZGFpbHlNZWRpYVVwbG9hZExpbWl0ElQKKG'
|
||||||
'1heGltYWxfdXBsb2FkX3NpemVfb2Zfc2luZ2xlX21lZGlhX3NpemUYBCABKANSIm1heGltYWxV'
|
'1heGltYWxfdXBsb2FkX3NpemVfb2Zfc2luZ2xlX21lZGlhX3NpemUYBCABKANSIm1heGltYWxV'
|
||||||
|
|
@ -691,64 +840,85 @@ final $typed_data.Uint8List responseDescriptor = $convert.base64Decode(
|
||||||
'A1IQbW9udGhseUNvc3RzQ2VudBIqChF5ZWFybHlfY29zdHNfY2VudBgIIAEoA1IPeWVhcmx5Q2'
|
'A1IQbW9udGhseUNvc3RzQ2VudBIqChF5ZWFybHlfY29zdHNfY2VudBgIIAEoA1IPeWVhcmx5Q2'
|
||||||
'9zdHNDZW50EkAKHWFsbG93ZWRfdG9fc2VuZF90ZXh0X21lc3NhZ2VzGAkgASgIUhlhbGxvd2Vk'
|
'9zdHNDZW50EkAKHWFsbG93ZWRfdG9fc2VuZF90ZXh0X21lc3NhZ2VzGAkgASgIUhlhbGxvd2Vk'
|
||||||
'VG9TZW5kVGV4dE1lc3NhZ2VzEjIKFWlzX2FkZGl0aW9uYWxfYWNjb3VudBgKIAEoCFITaXNBZG'
|
'VG9TZW5kVGV4dE1lc3NhZ2VzEjIKFWlzX2FkZGl0aW9uYWxfYWNjb3VudBgKIAEoCFITaXNBZG'
|
||||||
'RpdGlvbmFsQWNjb3VudBo+CgVQbGFucxI1CgVwbGFucxgBIAMoCzIfLnNlcnZlcl90b19jbGll'
|
'RpdGlvbmFsQWNjb3VudBIuChNtZW1vcmllc19zaXplX2xpbWl0GAsgASgDUhFtZW1vcmllc1Np'
|
||||||
'bnQuUmVzcG9uc2UuUGxhblIFcGxhbnMaTQoRQWRkQWNjb3VudHNJbnZpdGUSFwoHcGxhbl9pZB'
|
'emVMaW1pdBo+CgVQbGFucxI1CgVwbGFucxgBIAMoCzIfLnNlcnZlcl90b19jbGllbnQuUmVzcG'
|
||||||
'gBIAEoCVIGcGxhbklkEh8KC2ludml0ZV9jb2RlGAIgASgJUgppbnZpdGVDb2RlGlwKEkFkZEFj'
|
'9uc2UuUGxhblIFcGxhbnMaTQoRQWRkQWNjb3VudHNJbnZpdGUSFwoHcGxhbl9pZBgBIAEoCVIG'
|
||||||
'Y291bnRzSW52aXRlcxJGCgdpbnZpdGVzGAEgAygLMiwuc2VydmVyX3RvX2NsaWVudC5SZXNwb2'
|
'cGxhbklkEh8KC2ludml0ZV9jb2RlGAIgASgJUgppbnZpdGVDb2RlGlwKEkFkZEFjY291bnRzSW'
|
||||||
'5zZS5BZGRBY2NvdW50c0ludml0ZVIHaW52aXRlcxpFChFBZGRpdGlvbmFsQWNjb3VudBIXCgd1'
|
'52aXRlcxJGCgdpbnZpdGVzGAEgAygLMiwuc2VydmVyX3RvX2NsaWVudC5SZXNwb25zZS5BZGRB'
|
||||||
'c2VyX2lkGAEgASgDUgZ1c2VySWQSFwoHcGxhbl9pZBgDIAEoCVIGcGxhbklkGgwKCkRlcHJlY2'
|
'Y2NvdW50c0ludml0ZVIHaW52aXRlcxpFChFBZGRpdGlvbmFsQWNjb3VudBIXCgd1c2VyX2lkGA'
|
||||||
'F0ZWQaDQoLVHJhbnNhY3Rpb24alwUKDFBsYW5CYWxsYW5jZRJACh11c2VkX2RhaWx5X21lZGlh'
|
'EgASgDUgZ1c2VySWQSFwoHcGxhbl9pZBgDIAEoCVIGcGxhbklkGgwKCkRlcHJlY2F0ZWQaDQoL'
|
||||||
'X3VwbG9hZF9saW1pdBgBIAEoA1IZdXNlZERhaWx5TWVkaWFVcGxvYWRMaW1pdBI+Chx1c2VkX3'
|
'VHJhbnNhY3Rpb24alwUKDFBsYW5CYWxsYW5jZRJACh11c2VkX2RhaWx5X21lZGlhX3VwbG9hZF'
|
||||||
'VwbG9hZF9tZWRpYV9zaXplX2xpbWl0GAIgASgDUhh1c2VkVXBsb2FkTWVkaWFTaXplTGltaXQS'
|
'9saW1pdBgBIAEoA1IZdXNlZERhaWx5TWVkaWFVcGxvYWRMaW1pdBI+Chx1c2VkX3VwbG9hZF9t'
|
||||||
'MwoTcGF5bWVudF9wZXJpb2RfZGF5cxgDIAEoA0gAUhFwYXltZW50UGVyaW9kRGF5c4gBARJLCi'
|
'ZWRpYV9zaXplX2xpbWl0GAIgASgDUhh1c2VkVXBsb2FkTWVkaWFTaXplTGltaXQSMwoTcGF5bW'
|
||||||
'BsYXN0X3BheW1lbnRfZG9uZV91bml4X3RpbWVzdGFtcBgEIAEoA0gBUhxsYXN0UGF5bWVudERv'
|
'VudF9wZXJpb2RfZGF5cxgDIAEoA0gAUhFwYXltZW50UGVyaW9kRGF5c4gBARJLCiBsYXN0X3Bh'
|
||||||
'bmVVbml4VGltZXN0YW1wiAEBEkoKDHRyYW5zYWN0aW9ucxgFIAMoCzImLnNlcnZlcl90b19jbG'
|
'eW1lbnRfZG9uZV91bml4X3RpbWVzdGFtcBgEIAEoA0gBUhxsYXN0UGF5bWVudERvbmVVbml4VG'
|
||||||
'llbnQuUmVzcG9uc2UuVHJhbnNhY3Rpb25SDHRyYW5zYWN0aW9ucxJdChNhZGRpdGlvbmFsX2Fj'
|
'ltZXN0YW1wiAEBEkoKDHRyYW5zYWN0aW9ucxgFIAMoCzImLnNlcnZlcl90b19jbGllbnQuUmVz'
|
||||||
'Y291bnRzGAYgAygLMiwuc2VydmVyX3RvX2NsaWVudC5SZXNwb25zZS5BZGRpdGlvbmFsQWNjb3'
|
'cG9uc2UuVHJhbnNhY3Rpb25SDHRyYW5zYWN0aW9ucxJdChNhZGRpdGlvbmFsX2FjY291bnRzGA'
|
||||||
'VudFISYWRkaXRpb25hbEFjY291bnRzEiYKDGF1dG9fcmVuZXdhbBgHIAEoCEgCUgthdXRvUmVu'
|
'YgAygLMiwuc2VydmVyX3RvX2NsaWVudC5SZXNwb25zZS5BZGRpdGlvbmFsQWNjb3VudFISYWRk'
|
||||||
'ZXdhbIgBARJCChthZGRpdGlvbmFsX2FjY291bnRfb3duZXJfaWQYCCABKANIA1IYYWRkaXRpb2'
|
'aXRpb25hbEFjY291bnRzEiYKDGF1dG9fcmVuZXdhbBgHIAEoCEgCUgthdXRvUmVuZXdhbIgBAR'
|
||||||
'5hbEFjY291bnRPd25lcklkiAEBQhYKFF9wYXltZW50X3BlcmlvZF9kYXlzQiMKIV9sYXN0X3Bh'
|
'JCChthZGRpdGlvbmFsX2FjY291bnRfb3duZXJfaWQYCCABKANIA1IYYWRkaXRpb25hbEFjY291'
|
||||||
'eW1lbnRfZG9uZV91bml4X3RpbWVzdGFtcEIPCg1fYXV0b19yZW5ld2FsQh4KHF9hZGRpdGlvbm'
|
'bnRPd25lcklkiAEBQhYKFF9wYXltZW50X3BlcmlvZF9kYXlzQiMKIV9sYXN0X3BheW1lbnRfZG'
|
||||||
'FsX2FjY291bnRfb3duZXJfaWQaMAoGUHJlS2V5Eg4KAmlkGAEgASgDUgJpZBIWCgZwcmVrZXkY'
|
'9uZV91bml4X3RpbWVzdGFtcEIPCg1fYXV0b19yZW5ld2FsQh4KHF9hZGRpdGlvbmFsX2FjY291'
|
||||||
'AiABKAxSBnByZWtleRqVAQoMU2lnbmVkUHJlS2V5EigKEHNpZ25lZF9wcmVrZXlfaWQYASABKA'
|
'bnRfb3duZXJfaWQaMAoGUHJlS2V5Eg4KAmlkGAEgASgDUgJpZBIWCgZwcmVrZXkYAiABKAxSBn'
|
||||||
'NSDnNpZ25lZFByZWtleUlkEiMKDXNpZ25lZF9wcmVrZXkYAiABKAxSDHNpZ25lZFByZWtleRI2'
|
'ByZWtleRqVAQoMU2lnbmVkUHJlS2V5EigKEHNpZ25lZF9wcmVrZXlfaWQYASABKANSDnNpZ25l'
|
||||||
'ChdzaWduZWRfcHJla2V5X3NpZ25hdHVyZRgDIAEoDFIVc2lnbmVkUHJla2V5U2lnbmF0dXJlGv'
|
'ZFByZWtleUlkEiMKDXNpZ25lZF9wcmVrZXkYAiABKAxSDHNpZ25lZFByZWtleRI2ChdzaWduZW'
|
||||||
'YDCghVc2VyRGF0YRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSOwoHcHJla2V5cxgCIAMoCzIh'
|
'RfcHJla2V5X3NpZ25hdHVyZRgDIAEoDFIVc2lnbmVkUHJla2V5U2lnbmF0dXJlGvYDCghVc2Vy'
|
||||||
'LnNlcnZlcl90b19jbGllbnQuUmVzcG9uc2UuUHJlS2V5UgdwcmVrZXlzEh8KCHVzZXJuYW1lGA'
|
'RGF0YRIXCgd1c2VyX2lkGAEgASgDUgZ1c2VySWQSOwoHcHJla2V5cxgCIAMoCzIhLnNlcnZlcl'
|
||||||
'cgASgMSABSCHVzZXJuYW1liAEBEjMKE3B1YmxpY19pZGVudGl0eV9rZXkYAyABKAxIAVIRcHVi'
|
'90b19jbGllbnQuUmVzcG9uc2UuUHJlS2V5UgdwcmVrZXlzEh8KCHVzZXJuYW1lGAcgASgMSABS'
|
||||||
'bGljSWRlbnRpdHlLZXmIAQESKAoNc2lnbmVkX3ByZWtleRgEIAEoDEgCUgxzaWduZWRQcmVrZX'
|
'CHVzZXJuYW1liAEBEjMKE3B1YmxpY19pZGVudGl0eV9rZXkYAyABKAxIAVIRcHVibGljSWRlbn'
|
||||||
'mIAQESOwoXc2lnbmVkX3ByZWtleV9zaWduYXR1cmUYBSABKAxIA1IVc2lnbmVkUHJla2V5U2ln'
|
'RpdHlLZXmIAQESKAoNc2lnbmVkX3ByZWtleRgEIAEoDEgCUgxzaWduZWRQcmVrZXmIAQESOwoX'
|
||||||
'bmF0dXJliAEBEi0KEHNpZ25lZF9wcmVrZXlfaWQYBiABKANIBFIOc2lnbmVkUHJla2V5SWSIAQ'
|
'c2lnbmVkX3ByZWtleV9zaWduYXR1cmUYBSABKAxIA1IVc2lnbmVkUHJla2V5U2lnbmF0dXJliA'
|
||||||
'ESLAoPcmVnaXN0cmF0aW9uX2lkGAggASgDSAVSDnJlZ2lzdHJhdGlvbklkiAEBQgsKCV91c2Vy'
|
'EBEi0KEHNpZ25lZF9wcmVrZXlfaWQYBiABKANIBFIOc2lnbmVkUHJla2V5SWSIAQESLAoPcmVn'
|
||||||
'bmFtZUIWChRfcHVibGljX2lkZW50aXR5X2tleUIQCg5fc2lnbmVkX3ByZWtleUIaChhfc2lnbm'
|
'aXN0cmF0aW9uX2lkGAggASgDSAVSDnJlZ2lzdHJhdGlvbklkiAEBQgsKCV91c2VybmFtZUIWCh'
|
||||||
'VkX3ByZWtleV9zaWduYXR1cmVCEwoRX3NpZ25lZF9wcmVrZXlfaWRCEgoQX3JlZ2lzdHJhdGlv'
|
'RfcHVibGljX2lkZW50aXR5X2tleUIQCg5fc2lnbmVkX3ByZWtleUIaChhfc2lnbmVkX3ByZWtl'
|
||||||
'bl9pZBpZCgtVcGxvYWRUb2tlbhIhCgx1cGxvYWRfdG9rZW4YASABKAxSC3VwbG9hZFRva2VuEi'
|
'eV9zaWduYXR1cmVCEwoRX3NpZ25lZF9wcmVrZXlfaWRCEgoQX3JlZ2lzdHJhdGlvbl9pZBpZCg'
|
||||||
'cKD2Rvd25sb2FkX3Rva2VucxgCIAMoDFIOZG93bmxvYWRUb2tlbnMaOQoORG93bmxvYWRUb2tl'
|
'tVcGxvYWRUb2tlbhIhCgx1cGxvYWRfdG9rZW4YASABKAxSC3VwbG9hZFRva2VuEicKD2Rvd25s'
|
||||||
'bnMSJwoPZG93bmxvYWRfdG9rZW5zGAEgAygMUg5kb3dubG9hZFRva2VucxpFCgtQcm9vZk9mV2'
|
'b2FkX3Rva2VucxgCIAMoDFIOZG93bmxvYWRUb2tlbnMaOQoORG93bmxvYWRUb2tlbnMSJwoPZG'
|
||||||
'9yaxIWCgZwcmVmaXgYASABKAlSBnByZWZpeBIeCgpkaWZmaWN1bHR5GAIgASgDUgpkaWZmaWN1'
|
'93bmxvYWRfdG9rZW5zGAEgAygMUg5kb3dubG9hZFRva2VucxpFCgtQcm9vZk9mV29yaxIWCgZw'
|
||||||
'bHR5Gl4KH1Bhc3N3b3JkbGVzc05vdGlmaWNhdGlvbk1lc3NhZ2USDgoCaWQYASABKANSAmlkEi'
|
'cmVmaXgYASABKAlSBnByZWZpeBIeCgpkaWZmaWN1bHR5GAIgASgDUgpkaWZmaWN1bHR5Gl4KH1'
|
||||||
'sKEWVuY3J5cHRlZF9tZXNzYWdlGAIgASgMUhBlbmNyeXB0ZWRNZXNzYWdlGnoKIFBhc3N3b3Jk'
|
'Bhc3N3b3JkbGVzc05vdGlmaWNhdGlvbk1lc3NhZ2USDgoCaWQYASABKANSAmlkEisKEWVuY3J5'
|
||||||
'bGVzc05vdGlmaWNhdGlvbk1lc3NhZ2VzElYKCG1lc3NhZ2VzGAEgAygLMjouc2VydmVyX3RvX2'
|
'cHRlZF9tZXNzYWdlGAIgASgMUhBlbmNyeXB0ZWRNZXNzYWdlGnoKIFBhc3N3b3JkbGVzc05vdG'
|
||||||
'NsaWVudC5SZXNwb25zZS5QYXNzd29yZGxlc3NOb3RpZmljYXRpb25NZXNzYWdlUghtZXNzYWdl'
|
'lmaWNhdGlvbk1lc3NhZ2VzElYKCG1lc3NhZ2VzGAEgAygLMjouc2VydmVyX3RvX2NsaWVudC5S'
|
||||||
'cxqwCQoCT2sSFAoETm9uZRgBIAEoCEgAUgROb25lEhgKBnVzZXJpZBgCIAEoA0gAUgZ1c2VyaW'
|
'ZXNwb25zZS5QYXNzd29yZGxlc3NOb3RpZmljYXRpb25NZXNzYWdlUghtZXNzYWdlcxqqAQoNUH'
|
||||||
'QSJgoNYXV0aGNoYWxsZW5nZRgDIAEoDEgAUg1hdXRoY2hhbGxlbmdlEkoKC3VwbG9hZHRva2Vu'
|
'Jlc2lnbmVkUG9zdBIQCgN1cmwYASABKAlSA3VybBJMCgZmaWVsZHMYAiADKAsyNC5zZXJ2ZXJf'
|
||||||
'GAQgASgLMiYuc2VydmVyX3RvX2NsaWVudC5SZXNwb25zZS5VcGxvYWRUb2tlbkgAUgt1cGxvYW'
|
'dG9fY2xpZW50LlJlc3BvbnNlLlByZXNpZ25lZFBvc3QuRmllbGRzRW50cnlSBmZpZWxkcxo5Cg'
|
||||||
'R0b2tlbhJBCgh1c2VyZGF0YRgFIAEoCzIjLnNlcnZlcl90b19jbGllbnQuUmVzcG9uc2UuVXNl'
|
'tGaWVsZHNFbnRyeRIQCgNrZXkYASABKAlSA2tleRIUCgV2YWx1ZRgCIAEoCVIFdmFsdWU6AjgB'
|
||||||
'ckRhdGFIAFIIdXNlcmRhdGESHgoJYXV0aHRva2VuGAYgASgMSABSCWF1dGh0b2tlbhJKCgxkZX'
|
'Gs8BChJNZW1vcmllc1VwbG9hZFVybHMSGQoIbWVkaWFfaWQYASABKAlSB21lZGlhSWQSUwoQdG'
|
||||||
'ByZWNhdGVkXzcYByABKAsyJS5zZXJ2ZXJfdG9fY2xpZW50LlJlc3BvbnNlLkRlcHJlY2F0ZWRI'
|
'h1bWJuYWlsX3VwbG9hZBgCIAEoCzIoLnNlcnZlcl90b19jbGllbnQuUmVzcG9uc2UuUHJlc2ln'
|
||||||
'AFILZGVwcmVjYXRlZDcSUAoNYXV0aGVudGljYXRlZBgIIAEoCzIoLnNlcnZlcl90b19jbGllbn'
|
'bmVkUG9zdFIPdGh1bWJuYWlsVXBsb2FkEkkKC2Z1bGxfdXBsb2FkGAMgASgLMiguc2VydmVyX3'
|
||||||
'QuUmVzcG9uc2UuQXV0aGVudGljYXRlZEgAUg1hdXRoZW50aWNhdGVkEjgKBXBsYW5zGAkgASgL'
|
'RvX2NsaWVudC5SZXNwb25zZS5QcmVzaWduZWRQb3N0UgpmdWxsVXBsb2FkGoEBCglNZWRpYUl0'
|
||||||
'MiAuc2VydmVyX3RvX2NsaWVudC5SZXNwb25zZS5QbGFuc0gAUgVwbGFucxJNCgxwbGFuYmFsbG'
|
'ZW0SGQoIbWVkaWFfaWQYASABKAlSB21lZGlhSWQSIwoNb3JpZ2luYWxfZGF0ZRgCIAEoA1IMb3'
|
||||||
'FuY2UYCiABKAsyJy5zZXJ2ZXJfdG9fY2xpZW50LlJlc3BvbnNlLlBsYW5CYWxsYW5jZUgAUgxw'
|
'JpZ2luYWxEYXRlEjQKFnRodW1ibmFpbF9kb3dubG9hZF91cmwYAyABKAlSFHRodW1ibmFpbERv'
|
||||||
'bGFuYmFsbGFuY2USTAoNZGVwcmVjYXRlZF8xMRgLIAEoCzIlLnNlcnZlcl90b19jbGllbnQuUm'
|
'd25sb2FkVXJsGkoKDE1lbW9yaWVzTGlzdBI6CgVpdGVtcxgBIAMoCzIkLnNlcnZlcl90b19jbG'
|
||||||
'VzcG9uc2UuRGVwcmVjYXRlZEgAUgxkZXByZWNhdGVkMTESXwoSYWRkYWNjb3VudHNpbnZpdGVz'
|
'llbnQuUmVzcG9uc2UuTWVkaWFJdGVtUgVpdGVtcxo5CgtNZW1vcmllc1VybBIqChFmdWxsX2Rv'
|
||||||
'GAwgASgLMi0uc2VydmVyX3RvX2NsaWVudC5SZXNwb25zZS5BZGRBY2NvdW50c0ludml0ZXNIAF'
|
'd25sb2FkX3VybBgBIAEoCVIPZnVsbERvd25sb2FkVXJsGmcKDU1lbW9yaWVzVXNhZ2USGwoJbW'
|
||||||
'ISYWRkYWNjb3VudHNpbnZpdGVzElMKDmRvd25sb2FkdG9rZW5zGA0gASgLMikuc2VydmVyX3Rv'
|
'F4X2J5dGVzGAEgASgDUghtYXhCeXRlcxIjCg1jdXJyZW50X2J5dGVzGAIgASgDUgxjdXJyZW50'
|
||||||
'X2NsaWVudC5SZXNwb25zZS5Eb3dubG9hZFRva2Vuc0gAUg5kb3dubG9hZHRva2VucxJNCgxzaW'
|
'Qnl0ZXMSFAoFY291bnQYAyABKANSBWNvdW50GoMMCgJPaxIUCgROb25lGAEgASgISABSBE5vbm'
|
||||||
'duZWRwcmVrZXkYDiABKAsyJy5zZXJ2ZXJfdG9fY2xpZW50LlJlc3BvbnNlLlNpZ25lZFByZUtl'
|
'USGAoGdXNlcmlkGAIgASgDSABSBnVzZXJpZBImCg1hdXRoY2hhbGxlbmdlGAMgASgMSABSDWF1'
|
||||||
'eUgAUgxzaWduZWRwcmVrZXkSSgoLcHJvb2ZPZldvcmsYDyABKAsyJi5zZXJ2ZXJfdG9fY2xpZW'
|
'dGhjaGFsbGVuZ2USSgoLdXBsb2FkdG9rZW4YBCABKAsyJi5zZXJ2ZXJfdG9fY2xpZW50LlJlc3'
|
||||||
'50LlJlc3BvbnNlLlByb29mT2ZXb3JrSABSC3Byb29mT2ZXb3JrEkkKIHBhc3N3b3JkbGVzc19y'
|
'BvbnNlLlVwbG9hZFRva2VuSABSC3VwbG9hZHRva2VuEkEKCHVzZXJkYXRhGAUgASgLMiMuc2Vy'
|
||||||
'ZWNvdmVyeV9zZXJ2ZXJfa2V5GBAgASgMSABSHXBhc3N3b3JkbGVzc1JlY292ZXJ5U2VydmVyS2'
|
'dmVyX3RvX2NsaWVudC5SZXNwb25zZS5Vc2VyRGF0YUgAUgh1c2VyZGF0YRIeCglhdXRodG9rZW'
|
||||||
'V5EosBCiJwYXNzd29yZGxlc3Nfbm90aWZpY2F0aW9uX21lc3NhZ2VzGBEgASgLMjsuc2VydmVy'
|
'4YBiABKAxIAFIJYXV0aHRva2VuEkoKDGRlcHJlY2F0ZWRfNxgHIAEoCzIlLnNlcnZlcl90b19j'
|
||||||
'X3RvX2NsaWVudC5SZXNwb25zZS5QYXNzd29yZGxlc3NOb3RpZmljYXRpb25NZXNzYWdlc0gAUi'
|
'bGllbnQuUmVzcG9uc2UuRGVwcmVjYXRlZEgAUgtkZXByZWNhdGVkNxJQCg1hdXRoZW50aWNhdG'
|
||||||
'BwYXNzd29yZGxlc3NOb3RpZmljYXRpb25NZXNzYWdlc0IECgJPa0IKCghSZXNwb25zZQ==');
|
'VkGAggASgLMiguc2VydmVyX3RvX2NsaWVudC5SZXNwb25zZS5BdXRoZW50aWNhdGVkSABSDWF1'
|
||||||
|
'dGhlbnRpY2F0ZWQSOAoFcGxhbnMYCSABKAsyIC5zZXJ2ZXJfdG9fY2xpZW50LlJlc3BvbnNlLl'
|
||||||
|
'BsYW5zSABSBXBsYW5zEk0KDHBsYW5iYWxsYW5jZRgKIAEoCzInLnNlcnZlcl90b19jbGllbnQu'
|
||||||
|
'UmVzcG9uc2UuUGxhbkJhbGxhbmNlSABSDHBsYW5iYWxsYW5jZRJMCg1kZXByZWNhdGVkXzExGA'
|
||||||
|
'sgASgLMiUuc2VydmVyX3RvX2NsaWVudC5SZXNwb25zZS5EZXByZWNhdGVkSABSDGRlcHJlY2F0'
|
||||||
|
'ZWQxMRJfChJhZGRhY2NvdW50c2ludml0ZXMYDCABKAsyLS5zZXJ2ZXJfdG9fY2xpZW50LlJlc3'
|
||||||
|
'BvbnNlLkFkZEFjY291bnRzSW52aXRlc0gAUhJhZGRhY2NvdW50c2ludml0ZXMSUwoOZG93bmxv'
|
||||||
|
'YWR0b2tlbnMYDSABKAsyKS5zZXJ2ZXJfdG9fY2xpZW50LlJlc3BvbnNlLkRvd25sb2FkVG9rZW'
|
||||||
|
'5zSABSDmRvd25sb2FkdG9rZW5zEk0KDHNpZ25lZHByZWtleRgOIAEoCzInLnNlcnZlcl90b19j'
|
||||||
|
'bGllbnQuUmVzcG9uc2UuU2lnbmVkUHJlS2V5SABSDHNpZ25lZHByZWtleRJKCgtwcm9vZk9mV2'
|
||||||
|
'9yaxgPIAEoCzImLnNlcnZlcl90b19jbGllbnQuUmVzcG9uc2UuUHJvb2ZPZldvcmtIAFILcHJv'
|
||||||
|
'b2ZPZldvcmsSSQogcGFzc3dvcmRsZXNzX3JlY292ZXJ5X3NlcnZlcl9rZXkYECABKAxIAFIdcG'
|
||||||
|
'Fzc3dvcmRsZXNzUmVjb3ZlcnlTZXJ2ZXJLZXkSiwEKInBhc3N3b3JkbGVzc19ub3RpZmljYXRp'
|
||||||
|
'b25fbWVzc2FnZXMYESABKAsyOy5zZXJ2ZXJfdG9fY2xpZW50LlJlc3BvbnNlLlBhc3N3b3JkbG'
|
||||||
|
'Vzc05vdGlmaWNhdGlvbk1lc3NhZ2VzSABSIHBhc3N3b3JkbGVzc05vdGlmaWNhdGlvbk1lc3Nh'
|
||||||
|
'Z2VzEmEKFG1lbW9yaWVzX3VwbG9hZF91cmxzGBIgASgLMi0uc2VydmVyX3RvX2NsaWVudC5SZX'
|
||||||
|
'Nwb25zZS5NZW1vcmllc1VwbG9hZFVybHNIAFISbWVtb3JpZXNVcGxvYWRVcmxzEk4KDW1lbW9y'
|
||||||
|
'aWVzX2xpc3QYEyABKAsyJy5zZXJ2ZXJfdG9fY2xpZW50LlJlc3BvbnNlLk1lbW9yaWVzTGlzdE'
|
||||||
|
'gAUgxtZW1vcmllc0xpc3QSSwoMbWVtb3JpZXNfdXJsGBQgASgLMiYuc2VydmVyX3RvX2NsaWVu'
|
||||||
|
'dC5SZXNwb25zZS5NZW1vcmllc1VybEgAUgttZW1vcmllc1VybBJRCg5tZW1vcmllc191c2FnZR'
|
||||||
|
'gVIAEoCzIoLnNlcnZlcl90b19jbGllbnQuUmVzcG9uc2UuTWVtb3JpZXNVc2FnZUgAUg1tZW1v'
|
||||||
|
'cmllc1VzYWdlQgQKAk9rQgoKCFJlc3BvbnNl');
|
||||||
|
|
|
||||||
|
|
@ -10,3 +10,10 @@ message TwonlySafeBackupEncrypted {
|
||||||
bytes nonce = 2;
|
bytes nonce = 2;
|
||||||
bytes cipher_text = 3;
|
bytes cipher_text = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message CloudMediaBackupEncrypted {
|
||||||
|
string addition = 1;
|
||||||
|
bytes encrypted_media_key = 2; // Generated by Rust MainKey
|
||||||
|
bytes media_nonce = 3; // Nonce used for ChaCha20
|
||||||
|
bytes media_ciphertext = 4; // The actual media_ciphertext (including the Poly1305 MAC)
|
||||||
|
}
|
||||||
|
|
@ -164,6 +164,99 @@ class TwonlySafeBackupEncrypted extends $pb.GeneratedMessage {
|
||||||
void clearCipherText() => $_clearField(3);
|
void clearCipherText() => $_clearField(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class CloudMediaBackupEncrypted extends $pb.GeneratedMessage {
|
||||||
|
factory CloudMediaBackupEncrypted({
|
||||||
|
$core.String? addition,
|
||||||
|
$core.List<$core.int>? encryptedMediaKey,
|
||||||
|
$core.List<$core.int>? mediaNonce,
|
||||||
|
$core.List<$core.int>? mediaCiphertext,
|
||||||
|
}) {
|
||||||
|
final result = create();
|
||||||
|
if (addition != null) result.addition = addition;
|
||||||
|
if (encryptedMediaKey != null) result.encryptedMediaKey = encryptedMediaKey;
|
||||||
|
if (mediaNonce != null) result.mediaNonce = mediaNonce;
|
||||||
|
if (mediaCiphertext != null) result.mediaCiphertext = mediaCiphertext;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
CloudMediaBackupEncrypted._();
|
||||||
|
|
||||||
|
factory CloudMediaBackupEncrypted.fromBuffer($core.List<$core.int> data,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromBuffer(data, registry);
|
||||||
|
factory CloudMediaBackupEncrypted.fromJson($core.String json,
|
||||||
|
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
||||||
|
create()..mergeFromJson(json, registry);
|
||||||
|
|
||||||
|
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
||||||
|
_omitMessageNames ? '' : 'CloudMediaBackupEncrypted',
|
||||||
|
createEmptyInstance: create)
|
||||||
|
..aOS(1, _omitFieldNames ? '' : 'addition')
|
||||||
|
..a<$core.List<$core.int>>(
|
||||||
|
2, _omitFieldNames ? '' : 'encryptedMediaKey', $pb.PbFieldType.OY)
|
||||||
|
..a<$core.List<$core.int>>(
|
||||||
|
3, _omitFieldNames ? '' : 'mediaNonce', $pb.PbFieldType.OY)
|
||||||
|
..a<$core.List<$core.int>>(
|
||||||
|
4, _omitFieldNames ? '' : 'mediaCiphertext', $pb.PbFieldType.OY)
|
||||||
|
..hasRequiredFields = false;
|
||||||
|
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
CloudMediaBackupEncrypted clone() => deepCopy();
|
||||||
|
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
||||||
|
CloudMediaBackupEncrypted copyWith(
|
||||||
|
void Function(CloudMediaBackupEncrypted) updates) =>
|
||||||
|
super.copyWith((message) => updates(message as CloudMediaBackupEncrypted))
|
||||||
|
as CloudMediaBackupEncrypted;
|
||||||
|
|
||||||
|
@$core.override
|
||||||
|
$pb.BuilderInfo get info_ => _i;
|
||||||
|
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static CloudMediaBackupEncrypted create() => CloudMediaBackupEncrypted._();
|
||||||
|
@$core.override
|
||||||
|
CloudMediaBackupEncrypted createEmptyInstance() => create();
|
||||||
|
@$core.pragma('dart2js:noInline')
|
||||||
|
static CloudMediaBackupEncrypted getDefault() => _defaultInstance ??=
|
||||||
|
$pb.GeneratedMessage.$_defaultFor<CloudMediaBackupEncrypted>(create);
|
||||||
|
static CloudMediaBackupEncrypted? _defaultInstance;
|
||||||
|
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.String get addition => $_getSZ(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
set addition($core.String value) => $_setString(0, value);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
$core.bool hasAddition() => $_has(0);
|
||||||
|
@$pb.TagNumber(1)
|
||||||
|
void clearAddition() => $_clearField(1);
|
||||||
|
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$core.List<$core.int> get encryptedMediaKey => $_getN(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
set encryptedMediaKey($core.List<$core.int> value) => $_setBytes(1, value);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
$core.bool hasEncryptedMediaKey() => $_has(1);
|
||||||
|
@$pb.TagNumber(2)
|
||||||
|
void clearEncryptedMediaKey() => $_clearField(2);
|
||||||
|
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
$core.List<$core.int> get mediaNonce => $_getN(2);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
set mediaNonce($core.List<$core.int> value) => $_setBytes(2, value);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
$core.bool hasMediaNonce() => $_has(2);
|
||||||
|
@$pb.TagNumber(3)
|
||||||
|
void clearMediaNonce() => $_clearField(3);
|
||||||
|
|
||||||
|
@$pb.TagNumber(4)
|
||||||
|
$core.List<$core.int> get mediaCiphertext => $_getN(3);
|
||||||
|
@$pb.TagNumber(4)
|
||||||
|
set mediaCiphertext($core.List<$core.int> value) => $_setBytes(3, value);
|
||||||
|
@$pb.TagNumber(4)
|
||||||
|
$core.bool hasMediaCiphertext() => $_has(3);
|
||||||
|
@$pb.TagNumber(4)
|
||||||
|
void clearMediaCiphertext() => $_clearField(4);
|
||||||
|
}
|
||||||
|
|
||||||
const $core.bool _omitFieldNames =
|
const $core.bool _omitFieldNames =
|
||||||
$core.bool.fromEnvironment('protobuf.omit_field_names');
|
$core.bool.fromEnvironment('protobuf.omit_field_names');
|
||||||
const $core.bool _omitMessageNames =
|
const $core.bool _omitMessageNames =
|
||||||
|
|
|
||||||
|
|
@ -51,3 +51,27 @@ final $typed_data.Uint8List twonlySafeBackupEncryptedDescriptor =
|
||||||
$convert.base64Decode(
|
$convert.base64Decode(
|
||||||
'ChlUd29ubHlTYWZlQmFja3VwRW5jcnlwdGVkEhAKA21hYxgBIAEoDFIDbWFjEhQKBW5vbmNlGA'
|
'ChlUd29ubHlTYWZlQmFja3VwRW5jcnlwdGVkEhAKA21hYxgBIAEoDFIDbWFjEhQKBW5vbmNlGA'
|
||||||
'IgASgMUgVub25jZRIfCgtjaXBoZXJfdGV4dBgDIAEoDFIKY2lwaGVyVGV4dA==');
|
'IgASgMUgVub25jZRIfCgtjaXBoZXJfdGV4dBgDIAEoDFIKY2lwaGVyVGV4dA==');
|
||||||
|
|
||||||
|
@$core.Deprecated('Use cloudMediaBackupEncryptedDescriptor instead')
|
||||||
|
const CloudMediaBackupEncrypted$json = {
|
||||||
|
'1': 'CloudMediaBackupEncrypted',
|
||||||
|
'2': [
|
||||||
|
{'1': 'addition', '3': 1, '4': 1, '5': 9, '10': 'addition'},
|
||||||
|
{
|
||||||
|
'1': 'encrypted_media_key',
|
||||||
|
'3': 2,
|
||||||
|
'4': 1,
|
||||||
|
'5': 12,
|
||||||
|
'10': 'encryptedMediaKey'
|
||||||
|
},
|
||||||
|
{'1': 'media_nonce', '3': 3, '4': 1, '5': 12, '10': 'mediaNonce'},
|
||||||
|
{'1': 'media_ciphertext', '3': 4, '4': 1, '5': 12, '10': 'mediaCiphertext'},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Descriptor for `CloudMediaBackupEncrypted`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
|
final $typed_data.Uint8List cloudMediaBackupEncryptedDescriptor = $convert.base64Decode(
|
||||||
|
'ChlDbG91ZE1lZGlhQmFja3VwRW5jcnlwdGVkEhoKCGFkZGl0aW9uGAEgASgJUghhZGRpdGlvbh'
|
||||||
|
'IuChNlbmNyeXB0ZWRfbWVkaWFfa2V5GAIgASgMUhFlbmNyeXB0ZWRNZWRpYUtleRIfCgttZWRp'
|
||||||
|
'YV9ub25jZRgDIAEoDFIKbWVkaWFOb25jZRIpChBtZWRpYV9jaXBoZXJ0ZXh0GAQgASgMUg9tZW'
|
||||||
|
'RpYUNpcGhlcnRleHQ=');
|
||||||
|
|
|
||||||
|
|
@ -17,80 +17,8 @@ import 'package:protobuf/protobuf.dart' as $pb;
|
||||||
|
|
||||||
export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions;
|
export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions;
|
||||||
|
|
||||||
/// Send from the person who tries to recover their account.
|
/// Used as envelope for TrustedFriendShare and RecoveryData.
|
||||||
/// This can be done via a link, which will then be opend in the app of the contact.
|
/// Encrypted using XChaCha20-Poly1305.
|
||||||
/// The contact than has to manualy select from which user he got the request.
|
|
||||||
/// -> Using this phishing is harder, as the user has to manualy select the user to recovery
|
|
||||||
/// -> The user who wants to recover his account does not need to remember her old username
|
|
||||||
class RecoveryRequest extends $pb.GeneratedMessage {
|
|
||||||
factory RecoveryRequest({
|
|
||||||
$core.String? notificationId,
|
|
||||||
$core.List<$core.int>? publicKey,
|
|
||||||
}) {
|
|
||||||
final result = create();
|
|
||||||
if (notificationId != null) result.notificationId = notificationId;
|
|
||||||
if (publicKey != null) result.publicKey = publicKey;
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
RecoveryRequest._();
|
|
||||||
|
|
||||||
factory RecoveryRequest.fromBuffer($core.List<$core.int> data,
|
|
||||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
|
||||||
create()..mergeFromBuffer(data, registry);
|
|
||||||
factory RecoveryRequest.fromJson($core.String json,
|
|
||||||
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
|
|
||||||
create()..mergeFromJson(json, registry);
|
|
||||||
|
|
||||||
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
|
|
||||||
_omitMessageNames ? '' : 'RecoveryRequest',
|
|
||||||
package: const $pb.PackageName(
|
|
||||||
_omitMessageNames ? '' : 'passwordless_recovery'),
|
|
||||||
createEmptyInstance: create)
|
|
||||||
..aOS(1, _omitFieldNames ? '' : 'notificationId')
|
|
||||||
..a<$core.List<$core.int>>(
|
|
||||||
2, _omitFieldNames ? '' : 'publicKey', $pb.PbFieldType.OY)
|
|
||||||
..hasRequiredFields = false;
|
|
||||||
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
|
||||||
RecoveryRequest clone() => deepCopy();
|
|
||||||
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
|
|
||||||
RecoveryRequest copyWith(void Function(RecoveryRequest) updates) =>
|
|
||||||
super.copyWith((message) => updates(message as RecoveryRequest))
|
|
||||||
as RecoveryRequest;
|
|
||||||
|
|
||||||
@$core.override
|
|
||||||
$pb.BuilderInfo get info_ => _i;
|
|
||||||
|
|
||||||
@$core.pragma('dart2js:noInline')
|
|
||||||
static RecoveryRequest create() => RecoveryRequest._();
|
|
||||||
@$core.override
|
|
||||||
RecoveryRequest createEmptyInstance() => create();
|
|
||||||
@$core.pragma('dart2js:noInline')
|
|
||||||
static RecoveryRequest getDefault() => _defaultInstance ??=
|
|
||||||
$pb.GeneratedMessage.$_defaultFor<RecoveryRequest>(create);
|
|
||||||
static RecoveryRequest? _defaultInstance;
|
|
||||||
|
|
||||||
@$pb.TagNumber(1)
|
|
||||||
$core.String get notificationId => $_getSZ(0);
|
|
||||||
@$pb.TagNumber(1)
|
|
||||||
set notificationId($core.String value) => $_setString(0, value);
|
|
||||||
@$pb.TagNumber(1)
|
|
||||||
$core.bool hasNotificationId() => $_has(0);
|
|
||||||
@$pb.TagNumber(1)
|
|
||||||
void clearNotificationId() => $_clearField(1);
|
|
||||||
|
|
||||||
@$pb.TagNumber(2)
|
|
||||||
$core.List<$core.int> get publicKey => $_getN(1);
|
|
||||||
@$pb.TagNumber(2)
|
|
||||||
set publicKey($core.List<$core.int> value) => $_setBytes(1, value);
|
|
||||||
@$pb.TagNumber(2)
|
|
||||||
$core.bool hasPublicKey() => $_has(1);
|
|
||||||
@$pb.TagNumber(2)
|
|
||||||
void clearPublicKey() => $_clearField(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Used as envelope for TrustedFriendShare and RecoveryData
|
|
||||||
class EncryptedEnvelope extends $pb.GeneratedMessage {
|
class EncryptedEnvelope extends $pb.GeneratedMessage {
|
||||||
factory EncryptedEnvelope({
|
factory EncryptedEnvelope({
|
||||||
$core.List<$core.int>? encryptedData,
|
$core.List<$core.int>? encryptedData,
|
||||||
|
|
@ -254,8 +182,8 @@ class TrustedFriendShare_User extends $pb.GeneratedMessage {
|
||||||
void clearAvatar() => $_clearField(3);
|
void clearAvatar() => $_clearField(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send from the trusted friend to
|
/// Sent from the trusted friend to the recovering user via the server.
|
||||||
/// This is encrypted with the received public key.
|
/// This is symmetrically encrypted using the encryption key received from the recovery link.
|
||||||
class TrustedFriendShare extends $pb.GeneratedMessage {
|
class TrustedFriendShare extends $pb.GeneratedMessage {
|
||||||
factory TrustedFriendShare({
|
factory TrustedFriendShare({
|
||||||
TrustedFriendShare_User? trustedFriend,
|
TrustedFriendShare_User? trustedFriend,
|
||||||
|
|
@ -313,7 +241,7 @@ class TrustedFriendShare extends $pb.GeneratedMessage {
|
||||||
$pb.GeneratedMessage.$_defaultFor<TrustedFriendShare>(create);
|
$pb.GeneratedMessage.$_defaultFor<TrustedFriendShare>(create);
|
||||||
static TrustedFriendShare? _defaultInstance;
|
static TrustedFriendShare? _defaultInstance;
|
||||||
|
|
||||||
/// This allows to display the user which user has send him his recovery data.
|
/// This allows to display to the recovering user which trusted friend has sent their recovery data.
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
TrustedFriendShare_User get trustedFriend => $_getN(0);
|
TrustedFriendShare_User get trustedFriend => $_getN(0);
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
|
|
@ -325,7 +253,7 @@ class TrustedFriendShare extends $pb.GeneratedMessage {
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
TrustedFriendShare_User ensureTrustedFriend() => $_ensure(0);
|
TrustedFriendShare_User ensureTrustedFriend() => $_ensure(0);
|
||||||
|
|
||||||
/// This allows to display the userdata, showing that he is recovering the correct person.
|
/// This allows to display the userdata, showing that the trusted friend is recovering the correct person.
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
TrustedFriendShare_User get shareUser => $_getN(1);
|
TrustedFriendShare_User get shareUser => $_getN(1);
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
|
|
@ -337,7 +265,7 @@ class TrustedFriendShare extends $pb.GeneratedMessage {
|
||||||
@$pb.TagNumber(2)
|
@$pb.TagNumber(2)
|
||||||
TrustedFriendShare_User ensureShareUser() => $_ensure(1);
|
TrustedFriendShare_User ensureShareUser() => $_ensure(1);
|
||||||
|
|
||||||
/// The minimum threshold required to decrypte the shares.
|
/// The minimum threshold required to reconstruct the shares.
|
||||||
@$pb.TagNumber(3)
|
@$pb.TagNumber(3)
|
||||||
$core.int get threshold => $_getIZ(2);
|
$core.int get threshold => $_getIZ(2);
|
||||||
@$pb.TagNumber(3)
|
@$pb.TagNumber(3)
|
||||||
|
|
@ -347,7 +275,7 @@ class TrustedFriendShare extends $pb.GeneratedMessage {
|
||||||
@$pb.TagNumber(3)
|
@$pb.TagNumber(3)
|
||||||
void clearThreshold() => $_clearField(3);
|
void clearThreshold() => $_clearField(3);
|
||||||
|
|
||||||
/// The actual share which will become: SharedSecretData
|
/// The actual share which will be used to reconstruct SharedSecretData.
|
||||||
@$pb.TagNumber(4)
|
@$pb.TagNumber(4)
|
||||||
$core.List<$core.int> get sharedSecretData => $_getN(3);
|
$core.List<$core.int> get sharedSecretData => $_getN(3);
|
||||||
@$pb.TagNumber(4)
|
@$pb.TagNumber(4)
|
||||||
|
|
@ -427,20 +355,18 @@ class RecoveryData extends $pb.GeneratedMessage {
|
||||||
void clearKeyManager() => $_clearField(3);
|
void clearKeyManager() => $_clearField(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// After received all shares this is decrypted by the user restoring its own
|
/// After receiving threshold shares, this is reconstructed by the recovering user.
|
||||||
class SharedSecretData extends $pb.GeneratedMessage {
|
class SharedSecretData extends $pb.GeneratedMessage {
|
||||||
factory SharedSecretData({
|
factory SharedSecretData({
|
||||||
$core.List<$core.int>? recoveryData,
|
$core.List<$core.int>? recoveryData,
|
||||||
$core.List<$core.int>? encryptedServerKeyNonce,
|
$core.List<$core.int>? serverKeyProtection,
|
||||||
$core.List<$core.int>? pinSeed,
|
|
||||||
$core.List<$core.int>? pinUnlockToken,
|
$core.List<$core.int>? pinUnlockToken,
|
||||||
$core.String? emailHint,
|
$core.String? emailHint,
|
||||||
}) {
|
}) {
|
||||||
final result = create();
|
final result = create();
|
||||||
if (recoveryData != null) result.recoveryData = recoveryData;
|
if (recoveryData != null) result.recoveryData = recoveryData;
|
||||||
if (encryptedServerKeyNonce != null)
|
if (serverKeyProtection != null)
|
||||||
result.encryptedServerKeyNonce = encryptedServerKeyNonce;
|
result.serverKeyProtection = serverKeyProtection;
|
||||||
if (pinSeed != null) result.pinSeed = pinSeed;
|
|
||||||
if (pinUnlockToken != null) result.pinUnlockToken = pinUnlockToken;
|
if (pinUnlockToken != null) result.pinUnlockToken = pinUnlockToken;
|
||||||
if (emailHint != null) result.emailHint = emailHint;
|
if (emailHint != null) result.emailHint = emailHint;
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -463,9 +389,7 @@ class SharedSecretData extends $pb.GeneratedMessage {
|
||||||
..a<$core.List<$core.int>>(
|
..a<$core.List<$core.int>>(
|
||||||
1, _omitFieldNames ? '' : 'recoveryData', $pb.PbFieldType.OY)
|
1, _omitFieldNames ? '' : 'recoveryData', $pb.PbFieldType.OY)
|
||||||
..a<$core.List<$core.int>>(
|
..a<$core.List<$core.int>>(
|
||||||
3, _omitFieldNames ? '' : 'encryptedServerKeyNonce', $pb.PbFieldType.OY)
|
3, _omitFieldNames ? '' : 'serverKeyProtection', $pb.PbFieldType.OY)
|
||||||
..a<$core.List<$core.int>>(
|
|
||||||
4, _omitFieldNames ? '' : 'pinSeed', $pb.PbFieldType.OY)
|
|
||||||
..a<$core.List<$core.int>>(
|
..a<$core.List<$core.int>>(
|
||||||
5, _omitFieldNames ? '' : 'pinUnlockToken', $pb.PbFieldType.OY)
|
5, _omitFieldNames ? '' : 'pinUnlockToken', $pb.PbFieldType.OY)
|
||||||
..aOS(6, _omitFieldNames ? '' : 'emailHint')
|
..aOS(6, _omitFieldNames ? '' : 'emailHint')
|
||||||
|
|
@ -490,7 +414,7 @@ class SharedSecretData extends $pb.GeneratedMessage {
|
||||||
$pb.GeneratedMessage.$_defaultFor<SharedSecretData>(create);
|
$pb.GeneratedMessage.$_defaultFor<SharedSecretData>(create);
|
||||||
static SharedSecretData? _defaultInstance;
|
static SharedSecretData? _defaultInstance;
|
||||||
|
|
||||||
/// The recovery data is encrypted in case a second factor was chosen.
|
/// The recovery data (RecoveryData). It is encrypted with the server_key in case a second factor was chosen.
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
$core.List<$core.int> get recoveryData => $_getN(0);
|
$core.List<$core.int> get recoveryData => $_getN(0);
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
|
|
@ -500,40 +424,33 @@ class SharedSecretData extends $pb.GeneratedMessage {
|
||||||
@$pb.TagNumber(1)
|
@$pb.TagNumber(1)
|
||||||
void clearRecoveryData() => $_clearField(1);
|
void clearRecoveryData() => $_clearField(1);
|
||||||
|
|
||||||
|
/// Combined with the user's PIN/Email via HKDF to derive the key to decrypt the encrypted server_key.
|
||||||
|
/// The server NEVER sees this value.
|
||||||
@$pb.TagNumber(3)
|
@$pb.TagNumber(3)
|
||||||
$core.List<$core.int> get encryptedServerKeyNonce => $_getN(1);
|
$core.List<$core.int> get serverKeyProtection => $_getN(1);
|
||||||
@$pb.TagNumber(3)
|
@$pb.TagNumber(3)
|
||||||
set encryptedServerKeyNonce($core.List<$core.int> value) =>
|
set serverKeyProtection($core.List<$core.int> value) => $_setBytes(1, value);
|
||||||
$_setBytes(1, value);
|
|
||||||
@$pb.TagNumber(3)
|
@$pb.TagNumber(3)
|
||||||
$core.bool hasEncryptedServerKeyNonce() => $_has(1);
|
$core.bool hasServerKeyProtection() => $_has(1);
|
||||||
@$pb.TagNumber(3)
|
@$pb.TagNumber(3)
|
||||||
void clearEncryptedServerKeyNonce() => $_clearField(3);
|
void clearServerKeyProtection() => $_clearField(3);
|
||||||
|
|
||||||
@$pb.TagNumber(4)
|
|
||||||
$core.List<$core.int> get pinSeed => $_getN(2);
|
|
||||||
@$pb.TagNumber(4)
|
|
||||||
set pinSeed($core.List<$core.int> value) => $_setBytes(2, value);
|
|
||||||
@$pb.TagNumber(4)
|
|
||||||
$core.bool hasPinSeed() => $_has(2);
|
|
||||||
@$pb.TagNumber(4)
|
|
||||||
void clearPinSeed() => $_clearField(4);
|
|
||||||
|
|
||||||
|
/// Used to authenticate with the server to fetch the encrypted server_key.
|
||||||
@$pb.TagNumber(5)
|
@$pb.TagNumber(5)
|
||||||
$core.List<$core.int> get pinUnlockToken => $_getN(3);
|
$core.List<$core.int> get pinUnlockToken => $_getN(2);
|
||||||
@$pb.TagNumber(5)
|
@$pb.TagNumber(5)
|
||||||
set pinUnlockToken($core.List<$core.int> value) => $_setBytes(3, value);
|
set pinUnlockToken($core.List<$core.int> value) => $_setBytes(2, value);
|
||||||
@$pb.TagNumber(5)
|
@$pb.TagNumber(5)
|
||||||
$core.bool hasPinUnlockToken() => $_has(3);
|
$core.bool hasPinUnlockToken() => $_has(2);
|
||||||
@$pb.TagNumber(5)
|
@$pb.TagNumber(5)
|
||||||
void clearPinUnlockToken() => $_clearField(5);
|
void clearPinUnlockToken() => $_clearField(5);
|
||||||
|
|
||||||
@$pb.TagNumber(6)
|
@$pb.TagNumber(6)
|
||||||
$core.String get emailHint => $_getSZ(4);
|
$core.String get emailHint => $_getSZ(3);
|
||||||
@$pb.TagNumber(6)
|
@$pb.TagNumber(6)
|
||||||
set emailHint($core.String value) => $_setString(4, value);
|
set emailHint($core.String value) => $_setString(3, value);
|
||||||
@$pb.TagNumber(6)
|
@$pb.TagNumber(6)
|
||||||
$core.bool hasEmailHint() => $_has(4);
|
$core.bool hasEmailHint() => $_has(3);
|
||||||
@$pb.TagNumber(6)
|
@$pb.TagNumber(6)
|
||||||
void clearEmailHint() => $_clearField(6);
|
void clearEmailHint() => $_clearField(6);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,20 +15,6 @@ import 'dart:convert' as $convert;
|
||||||
import 'dart:core' as $core;
|
import 'dart:core' as $core;
|
||||||
import 'dart:typed_data' as $typed_data;
|
import 'dart:typed_data' as $typed_data;
|
||||||
|
|
||||||
@$core.Deprecated('Use recoveryRequestDescriptor instead')
|
|
||||||
const RecoveryRequest$json = {
|
|
||||||
'1': 'RecoveryRequest',
|
|
||||||
'2': [
|
|
||||||
{'1': 'notification_id', '3': 1, '4': 1, '5': 9, '10': 'notificationId'},
|
|
||||||
{'1': 'public_key', '3': 2, '4': 1, '5': 12, '10': 'publicKey'},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Descriptor for `RecoveryRequest`. Decode as a `google.protobuf.DescriptorProto`.
|
|
||||||
final $typed_data.Uint8List recoveryRequestDescriptor = $convert.base64Decode(
|
|
||||||
'Cg9SZWNvdmVyeVJlcXVlc3QSJwoPbm90aWZpY2F0aW9uX2lkGAEgASgJUg5ub3RpZmljYXRpb2'
|
|
||||||
'5JZBIdCgpwdWJsaWNfa2V5GAIgASgMUglwdWJsaWNLZXk=');
|
|
||||||
|
|
||||||
@$core.Deprecated('Use encryptedEnvelopeDescriptor instead')
|
@$core.Deprecated('Use encryptedEnvelopeDescriptor instead')
|
||||||
const EncryptedEnvelope$json = {
|
const EncryptedEnvelope$json = {
|
||||||
'1': 'EncryptedEnvelope',
|
'1': 'EncryptedEnvelope',
|
||||||
|
|
@ -116,21 +102,12 @@ const SharedSecretData$json = {
|
||||||
'2': [
|
'2': [
|
||||||
{'1': 'recovery_data', '3': 1, '4': 1, '5': 12, '10': 'recoveryData'},
|
{'1': 'recovery_data', '3': 1, '4': 1, '5': 12, '10': 'recoveryData'},
|
||||||
{
|
{
|
||||||
'1': 'encrypted_server_key_nonce',
|
'1': 'server_key_protection',
|
||||||
'3': 3,
|
'3': 3,
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 12,
|
'5': 12,
|
||||||
'9': 0,
|
'9': 0,
|
||||||
'10': 'encryptedServerKeyNonce',
|
'10': 'serverKeyProtection',
|
||||||
'17': true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'1': 'pin_seed',
|
|
||||||
'3': 4,
|
|
||||||
'4': 1,
|
|
||||||
'5': 12,
|
|
||||||
'9': 1,
|
|
||||||
'10': 'pinSeed',
|
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -138,7 +115,7 @@ const SharedSecretData$json = {
|
||||||
'3': 5,
|
'3': 5,
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 12,
|
'5': 12,
|
||||||
'9': 2,
|
'9': 1,
|
||||||
'10': 'pinUnlockToken',
|
'10': 'pinUnlockToken',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
|
|
@ -147,14 +124,13 @@ const SharedSecretData$json = {
|
||||||
'3': 6,
|
'3': 6,
|
||||||
'4': 1,
|
'4': 1,
|
||||||
'5': 9,
|
'5': 9,
|
||||||
'9': 3,
|
'9': 2,
|
||||||
'10': 'emailHint',
|
'10': 'emailHint',
|
||||||
'17': true
|
'17': true
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'8': [
|
'8': [
|
||||||
{'1': '_encrypted_server_key_nonce'},
|
{'1': '_server_key_protection'},
|
||||||
{'1': '_pin_seed'},
|
|
||||||
{'1': '_pin_unlock_token'},
|
{'1': '_pin_unlock_token'},
|
||||||
{'1': '_email_hint'},
|
{'1': '_email_hint'},
|
||||||
],
|
],
|
||||||
|
|
@ -163,8 +139,7 @@ const SharedSecretData$json = {
|
||||||
/// Descriptor for `SharedSecretData`. Decode as a `google.protobuf.DescriptorProto`.
|
/// Descriptor for `SharedSecretData`. Decode as a `google.protobuf.DescriptorProto`.
|
||||||
final $typed_data.Uint8List sharedSecretDataDescriptor = $convert.base64Decode(
|
final $typed_data.Uint8List sharedSecretDataDescriptor = $convert.base64Decode(
|
||||||
'ChBTaGFyZWRTZWNyZXREYXRhEiMKDXJlY292ZXJ5X2RhdGEYASABKAxSDHJlY292ZXJ5RGF0YR'
|
'ChBTaGFyZWRTZWNyZXREYXRhEiMKDXJlY292ZXJ5X2RhdGEYASABKAxSDHJlY292ZXJ5RGF0YR'
|
||||||
'JAChplbmNyeXB0ZWRfc2VydmVyX2tleV9ub25jZRgDIAEoDEgAUhdlbmNyeXB0ZWRTZXJ2ZXJL'
|
'I3ChVzZXJ2ZXJfa2V5X3Byb3RlY3Rpb24YAyABKAxIAFITc2VydmVyS2V5UHJvdGVjdGlvbogB'
|
||||||
'ZXlOb25jZYgBARIeCghwaW5fc2VlZBgEIAEoDEgBUgdwaW5TZWVkiAEBEi0KEHBpbl91bmxvY2'
|
'ARItChBwaW5fdW5sb2NrX3Rva2VuGAUgASgMSAFSDnBpblVubG9ja1Rva2VuiAEBEiIKCmVtYW'
|
||||||
'tfdG9rZW4YBSABKAxIAlIOcGluVW5sb2NrVG9rZW6IAQESIgoKZW1haWxfaGludBgGIAEoCUgD'
|
'lsX2hpbnQYBiABKAlIAlIJZW1haWxIaW50iAEBQhgKFl9zZXJ2ZXJfa2V5X3Byb3RlY3Rpb25C'
|
||||||
'UgllbWFpbEhpbnSIAQFCHQobX2VuY3J5cHRlZF9zZXJ2ZXJfa2V5X25vbmNlQgsKCV9waW5fc2'
|
'EwoRX3Bpbl91bmxvY2tfdG9rZW5CDQoLX2VtYWlsX2hpbnQ=');
|
||||||
'VlZEITChFfcGluX3VubG9ja190b2tlbkINCgtfZW1haWxfaGludA==');
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,28 @@
|
||||||
syntax = "proto3";
|
syntax = "proto3";
|
||||||
package passwordless_recovery;
|
package passwordless_recovery;
|
||||||
|
|
||||||
// Recovery Process
|
// Used as envelope for TrustedFriendShare and RecoveryData.
|
||||||
// - Generating: TempID and a new assymetric key pair
|
// Encrypted using XChaCha20-Poly1305.
|
||||||
// - Uploading to the server TempID + Push Tokens so the server can notify the user that someone helped him
|
|
||||||
|
|
||||||
|
|
||||||
// Send from the person who tries to recover their account.
|
|
||||||
// This can be done via a link, which will then be opend in the app of the contact.
|
|
||||||
// The contact than has to manualy select from which user he got the request.
|
|
||||||
// -> Using this phishing is harder, as the user has to manualy select the user to recovery
|
|
||||||
// -> The user who wants to recover his account does not need to remember her old username
|
|
||||||
message RecoveryRequest {
|
|
||||||
string notification_id = 1;
|
|
||||||
bytes public_key = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Used as envelope for TrustedFriendShare and RecoveryData
|
|
||||||
message EncryptedEnvelope {
|
message EncryptedEnvelope {
|
||||||
bytes encrypted_data = 1;
|
bytes encrypted_data = 1;
|
||||||
bytes iv = 2;
|
bytes iv = 2;
|
||||||
bytes mac = 3;
|
bytes mac = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send from the trusted friend to
|
// Sent from the trusted friend to the recovering user via the server.
|
||||||
// This is encrypted with the received public key.
|
// This is symmetrically encrypted using the encryption key received from the recovery link.
|
||||||
message TrustedFriendShare {
|
message TrustedFriendShare {
|
||||||
|
|
||||||
// This allows to display the user which user has send him his recovery data.
|
// This allows to display to the recovering user which trusted friend has sent their recovery data.
|
||||||
User trusted_friend = 1;
|
User trusted_friend = 1;
|
||||||
|
|
||||||
// This allows to display the userdata, showing that he is recovering the correct person.
|
// This allows to display the userdata, showing that the trusted friend is recovering the correct person.
|
||||||
User share_user = 2;
|
User share_user = 2;
|
||||||
|
|
||||||
// The minimum threshold required to decrypte the shares.
|
// The minimum threshold required to reconstruct the shares.
|
||||||
int32 threshold = 3;
|
int32 threshold = 3;
|
||||||
|
|
||||||
// The actual share which will become: SharedSecretData
|
// The actual share which will be used to reconstruct SharedSecretData.
|
||||||
bytes shared_secret_data = 4;
|
bytes shared_secret_data = 4;
|
||||||
|
|
||||||
message User {
|
message User {
|
||||||
|
|
@ -52,18 +38,18 @@ message RecoveryData {
|
||||||
bytes key_manager = 3;
|
bytes key_manager = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
// After received all shares this is decrypted by the user restoring its own
|
// After receiving threshold shares, this is reconstructed by the recovering user.
|
||||||
message SharedSecretData {
|
message SharedSecretData {
|
||||||
|
|
||||||
// The recovery data is encrypted in case a second factor was chosen.
|
// The recovery data (RecoveryData). It is encrypted with the server_key in case a second factor was chosen.
|
||||||
bytes recovery_data = 1;
|
bytes recovery_data = 1;
|
||||||
|
|
||||||
optional bytes encrypted_server_key_nonce = 3;
|
// Combined with the user's PIN/Email via HKDF to derive the key to decrypt the encrypted server_key.
|
||||||
|
// The server NEVER sees this value.
|
||||||
|
optional bytes server_key_protection = 3;
|
||||||
|
|
||||||
optional bytes pin_seed = 4;
|
// Used to authenticate with the server to fetch the encrypted server_key.
|
||||||
optional bytes pin_unlock_token = 5;
|
optional bytes pin_unlock_token = 5;
|
||||||
|
|
||||||
optional string email_hint = 6;
|
optional string email_hint = 6;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import 'package:twonly/src/visual/views/settings/help/diagnostics.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/help/faq.view.dart';
|
import 'package:twonly/src/visual/views/settings/help/faq.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/help/faq/verification_badge_faq.view.dart';
|
import 'package:twonly/src/visual/views/settings/help/faq/verification_badge_faq.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/help/help.view.dart';
|
import 'package:twonly/src/visual/views/settings/help/help.view.dart';
|
||||||
|
import 'package:twonly/src/visual/views/settings/help/news.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/notification.view.dart';
|
import 'package:twonly/src/visual/views/settings/notification.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/privacy.view.dart';
|
import 'package:twonly/src/visual/views/settings/privacy.view.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/privacy/block_users.view.dart';
|
import 'package:twonly/src/visual/views/settings/privacy/block_users.view.dart';
|
||||||
|
|
@ -259,6 +260,10 @@ final routerProvider = GoRouter(
|
||||||
path: 'contact_us',
|
path: 'contact_us',
|
||||||
builder: (context, state) => const ContactUsView(),
|
builder: (context, state) => const ContactUsView(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: 'news',
|
||||||
|
builder: (context, state) => const NewsView(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'diagnostics',
|
path: 'diagnostics',
|
||||||
builder: (context, state) => const DiagnosticsView(),
|
builder: (context, state) => const DiagnosticsView(),
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import 'package:twonly/src/services/api/server_messages.api.dart';
|
||||||
import 'package:twonly/src/services/api/utils.api.dart';
|
import 'package:twonly/src/services/api/utils.api.dart';
|
||||||
import 'package:twonly/src/services/flame.service.dart';
|
import 'package:twonly/src/services/flame.service.dart';
|
||||||
import 'package:twonly/src/services/group.service.dart';
|
import 'package:twonly/src/services/group.service.dart';
|
||||||
|
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
|
||||||
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
|
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
|
||||||
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
|
import 'package:twonly/src/services/notifications/pushkeys.notifications.dart';
|
||||||
import 'package:twonly/src/services/passwordless_recovery.service.dart';
|
import 'package:twonly/src/services/passwordless_recovery.service.dart';
|
||||||
|
|
@ -143,6 +144,7 @@ class ApiService {
|
||||||
unawaited(PasswordlessRecoveryService.performHeartbeat());
|
unawaited(PasswordlessRecoveryService.performHeartbeat());
|
||||||
|
|
||||||
unawaited(UserDiscoveryService.checkForNewAnnouncedUsers());
|
unawaited(UserDiscoveryService.checkForNewAnnouncedUsers());
|
||||||
|
memoriesCloudService.init();
|
||||||
|
|
||||||
if (userService.currentUser.userStudyParticipantsToken != null) {
|
if (userService.currentUser.userStudyParticipantsToken != null) {
|
||||||
// In case the user participates in the user study, call the handler after authenticated, to be sure there is a internet connection
|
// In case the user participates in the user study, call the handler after authenticated, to be sure there is a internet connection
|
||||||
|
|
@ -710,6 +712,82 @@ class ApiService {
|
||||||
return sendRequestSync(req);
|
return sendRequestSync(req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<server.Response_MemoriesUploadUrls?> requestMemoriesUpload(
|
||||||
|
int sizeBytes,
|
||||||
|
DateTime originalDate,
|
||||||
|
String mediaId,
|
||||||
|
) async {
|
||||||
|
final get = ApplicationData_RequestMemoriesUpload()
|
||||||
|
..size = Int64(sizeBytes)
|
||||||
|
..originalDate = Int64(originalDate.millisecondsSinceEpoch)
|
||||||
|
..mediaId = mediaId;
|
||||||
|
final appData = ApplicationData()..requestMemoriesUpload = get;
|
||||||
|
final req = createClientToServerFromApplicationData(appData);
|
||||||
|
final res = await sendRequestSync(req);
|
||||||
|
if (res.isSuccess) {
|
||||||
|
final ok = res.value as server.Response_Ok;
|
||||||
|
if (ok.hasMemoriesUploadUrls()) {
|
||||||
|
return ok.memoriesUploadUrls;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<server.Response_MemoriesUsage?> getMemoriesUsage() async {
|
||||||
|
final appData = ApplicationData()
|
||||||
|
..getMemoriesUsage = ApplicationData_GetMemoriesUsage();
|
||||||
|
final req = createClientToServerFromApplicationData(appData);
|
||||||
|
final res = await sendRequestSync(req);
|
||||||
|
if (res.isSuccess) {
|
||||||
|
final ok = res.value as server.Response_Ok;
|
||||||
|
if (ok.hasMemoriesUsage()) {
|
||||||
|
return ok.memoriesUsage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<server.Response_MemoriesUrl?> getMemoriesUrl(
|
||||||
|
String mediaId,
|
||||||
|
bool thumbnail,
|
||||||
|
) async {
|
||||||
|
final appData = ApplicationData()
|
||||||
|
..getMemoriesUrl = ApplicationData_GetMemoriesUrl(
|
||||||
|
mediaId: mediaId,
|
||||||
|
thumbnail: thumbnail,
|
||||||
|
);
|
||||||
|
final req = createClientToServerFromApplicationData(appData);
|
||||||
|
final res = await sendRequestSync(req);
|
||||||
|
if (res.isSuccess) {
|
||||||
|
final ok = res.value as server.Response_Ok;
|
||||||
|
if (ok.hasMemoriesUrl()) {
|
||||||
|
return ok.memoriesUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result> confirmMemoriesUpload(String mediaId) async {
|
||||||
|
final get = ApplicationData_ConfirmMemoriesUpload()..mediaId = mediaId;
|
||||||
|
final appData = ApplicationData()..confirmMemoriesUpload = get;
|
||||||
|
final req = createClientToServerFromApplicationData(appData);
|
||||||
|
return sendRequestSync(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result> deleteMemory(String mediaId) async {
|
||||||
|
final get = ApplicationData_DeleteMemory()..mediaId = mediaId;
|
||||||
|
final appData = ApplicationData()..deleteMemory = get;
|
||||||
|
final req = createClientToServerFromApplicationData(appData);
|
||||||
|
return sendRequestSync(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Result> disableMemoriesBackup() async {
|
||||||
|
final get = ApplicationData_DisableMemoriesBackup();
|
||||||
|
final appData = ApplicationData()..disableMemoriesBackup = get;
|
||||||
|
final req = createClientToServerFromApplicationData(appData);
|
||||||
|
return sendRequestSync(req);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int?> getUserIdFromUsername(String username) async {
|
Future<int?> getUserIdFromUsername(String username) async {
|
||||||
final appData = Handshake(
|
final appData = Handshake(
|
||||||
getUseridByUsername: Handshake_GetUserIdByUsername(username: username),
|
getUseridByUsername: Handshake_GetUserIdByUsername(username: username),
|
||||||
|
|
@ -778,15 +856,15 @@ class ApiService {
|
||||||
|
|
||||||
Future<Result> getServerKeyForPasswordlessRecovery({
|
Future<Result> getServerKeyForPasswordlessRecovery({
|
||||||
required int userId,
|
required int userId,
|
||||||
List<int>? encryptedServerKeyNone,
|
List<int>? serverKeyProtection,
|
||||||
List<int>? pinUnlockToken,
|
List<int>? pinUnlockToken,
|
||||||
List<int>? pinProtectionKey,
|
List<int>? pinProtectionKey,
|
||||||
String? email,
|
String? email,
|
||||||
}) async {
|
}) async {
|
||||||
final get = Handshake_GetServerKeyForPasswordLessRecovery()
|
final get = Handshake_GetServerKeyForPasswordLessRecovery()
|
||||||
..userId = Int64(userId);
|
..userId = Int64(userId);
|
||||||
if (encryptedServerKeyNone != null) {
|
if (serverKeyProtection != null) {
|
||||||
get.encryptedServerKeyNone = encryptedServerKeyNone;
|
get.serverKeyProtection = serverKeyProtection;
|
||||||
}
|
}
|
||||||
if (pinUnlockToken != null) {
|
if (pinUnlockToken != null) {
|
||||||
get.pinUnlockToken = pinUnlockToken;
|
get.pinUnlockToken = pinUnlockToken;
|
||||||
|
|
@ -803,7 +881,6 @@ class ApiService {
|
||||||
return sendRequestSync(req, authenticated: false);
|
return sendRequestSync(req, authenticated: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<Result> submitRecoveryShare({
|
Future<Result> submitRecoveryShare({
|
||||||
required String notificationId,
|
required String notificationId,
|
||||||
required List<int> encryptedMessage,
|
required List<int> encryptedMessage,
|
||||||
|
|
@ -954,7 +1031,8 @@ class ApiService {
|
||||||
|
|
||||||
/// Polls the server for new passwordless recovery notification messages.
|
/// Polls the server for new passwordless recovery notification messages.
|
||||||
/// [alreadyReceivedIds] prevents the server from sending duplicates.
|
/// [alreadyReceivedIds] prevents the server from sending duplicates.
|
||||||
Future<server.Response_PasswordlessNotificationMessages?> checkForPasswordlessNotification({
|
Future<server.Response_PasswordlessNotificationMessages?>
|
||||||
|
checkForPasswordlessNotification({
|
||||||
required String notificationId,
|
required String notificationId,
|
||||||
required List<int> downloadAuthToken,
|
required List<int> downloadAuthToken,
|
||||||
List<Int64>? alreadyReceivedIds,
|
List<Int64>? alreadyReceivedIds,
|
||||||
|
|
@ -22,6 +22,7 @@ Future<void> initFileDownloader() async {
|
||||||
if (update.task.taskId.contains('download_')) {
|
if (update.task.taskId.contains('download_')) {
|
||||||
await handleDownloadStatusUpdate(update);
|
await handleDownloadStatusUpdate(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (update.task.taskId.contains('backup_')) {
|
if (update.task.taskId.contains('backup_')) {
|
||||||
await BackupService.handleBackupStatusUpdate(
|
await BackupService.handleBackupStatusUpdate(
|
||||||
update.task.taskId,
|
update.task.taskId,
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,19 @@ Future<void> finishStartedPreprocessing() async {
|
||||||
Log.info('Ignoring media files as it is a draft');
|
Log.info('Ignoring media files as it is a draft');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final messages = await twonlyDB.messagesDao.getMessagesByMediaId(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
);
|
||||||
|
if (messages.isEmpty) {
|
||||||
|
Log.info(
|
||||||
|
'Deleted orphaned media file ${mediaFile.mediaId} as no messages reference it.',
|
||||||
|
);
|
||||||
|
MediaFileService(mediaFile).fullMediaRemoval();
|
||||||
|
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaFile.mediaId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final service = MediaFileService(mediaFile);
|
final service = MediaFileService(mediaFile);
|
||||||
if (!service.originalPath.existsSync() &&
|
if (!service.originalPath.existsSync() &&
|
||||||
|
|
@ -492,20 +505,51 @@ Future<void> _startBackgroundMediaUploadInternal(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mediaService.encryptedPath.existsSync()) {
|
if (!mediaService.encryptedPath.existsSync()) {
|
||||||
|
Log.info(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Encrypted file not found. Starting encryption.',
|
||||||
|
);
|
||||||
await _encryptMediaFiles(mediaService);
|
await _encryptMediaFiles(mediaService);
|
||||||
if (!mediaService.encryptedPath.existsSync()) {
|
if (!mediaService.encryptedPath.existsSync()) {
|
||||||
|
Log.warn(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Encryption failed. Encrypted file still missing.',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
Log.info(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Encryption completed successfully.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Log.info(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Encrypted file already exists.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mediaService.uploadRequestPath.existsSync()) {
|
if (!mediaService.uploadRequestPath.existsSync()) {
|
||||||
|
Log.info(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Upload request file not found. Creating it.',
|
||||||
|
);
|
||||||
await _createUploadRequest(mediaService);
|
await _createUploadRequest(mediaService);
|
||||||
|
if (!mediaService.uploadRequestPath.existsSync()) {
|
||||||
|
Log.warn(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Upload request file creation returned empty (e.g. no messages).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.info(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Upload request file already exists.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mediaService.uploadRequestPath.existsSync()) {
|
if (mediaService.uploadRequestPath.existsSync()) {
|
||||||
|
Log.info(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Transitioning uploadState from ${mediaService.mediaFile.uploadState} to uploading.',
|
||||||
|
);
|
||||||
await mediaService.setUploadState(UploadState.uploading);
|
await mediaService.setUploadState(UploadState.uploading);
|
||||||
// at this point the original file is not used any more, so it can be deleted
|
// at this point the original file is not used any more, so it can be deleted
|
||||||
if (mediaService.originalPath.existsSync()) {
|
if (mediaService.originalPath.existsSync()) {
|
||||||
|
Log.info(
|
||||||
|
'Media ${mediaService.mediaFile.mediaId}: Deleting original file as it is now encrypted.',
|
||||||
|
);
|
||||||
mediaService.originalPath.deleteSync();
|
mediaService.originalPath.deleteSync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -553,6 +597,9 @@ Future<void> _createUploadRequest(MediaFileService media) async {
|
||||||
|
|
||||||
if (messages.isEmpty) {
|
if (messages.isEmpty) {
|
||||||
// There where no user selected who should receive the image, so waiting with this step...
|
// There where no user selected who should receive the image, so waiting with this step...
|
||||||
|
Log.info(
|
||||||
|
'Media ${media.mediaFile.mediaId}: No recipient messages found, waiting to create upload request',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ class BackupService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) {
|
if (recoveryData.state == BackupRecoveryState.archiveBackupStarted) {
|
||||||
// The KeyManager was restored sucessfully, restore the archive now.
|
// The KeyManager was restored successfully, restore the archive now.
|
||||||
try {
|
try {
|
||||||
final downloadToken =
|
final downloadToken =
|
||||||
await RustBackupArchive.getBackupDownloadToken();
|
await RustBackupArchive.getBackupDownloadToken();
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,7 @@ Future<bool> handleIntentUrl(BuildContext context, Uri uri) async {
|
||||||
// Check if this is the QR code link which was
|
// Check if this is the QR code link which was
|
||||||
// therefore scanned with the system camera
|
// therefore scanned with the system camera
|
||||||
|
|
||||||
if (kDebugMode &&
|
if (uri.toString().startsWith(PasswordlessRecoveryService.linkPrefix)) {
|
||||||
uri.toString().startsWith(PasswordlessRecoveryService.linkPrefix)) {
|
|
||||||
await PasswordlessRecoveryService.handleRecoveryLink(uri.toString());
|
await PasswordlessRecoveryService.handleRecoveryLink(uri.toString());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:cryptography_plus/cryptography_plus.dart';
|
import 'package:cryptography_plus/cryptography_plus.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||||
import 'package:twonly/src/database/tables/contacts.table.dart';
|
import 'package:twonly/src/database/tables/contacts.table.dart';
|
||||||
|
|
@ -17,6 +18,24 @@ import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart';
|
import 'package:twonly/src/visual/components/verification_success_dialog.comp.dart';
|
||||||
|
|
||||||
class KeyVerificationService {
|
class KeyVerificationService {
|
||||||
|
static final StreamController<void> _verificationSuccessCloseController =
|
||||||
|
StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
static Stream<void> get onVerificationSuccessClose =>
|
||||||
|
_verificationSuccessCloseController.stream;
|
||||||
|
|
||||||
|
static void notifyVerificationSuccessClose() {
|
||||||
|
_verificationSuccessCloseController.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void closeVerificationFlows(BuildContext context) {
|
||||||
|
Navigator.popUntil(context, (route) {
|
||||||
|
final name = route.settings.name;
|
||||||
|
if (name == null) return false;
|
||||||
|
return !name.contains('qr_scanner') && !name.contains('verifybadge');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static Future<List<int>> getNewSecretVerificationToken() async {
|
static Future<List<int>> getNewSecretVerificationToken() async {
|
||||||
final token = getRandomUint8List(16);
|
final token = getRandomUint8List(16);
|
||||||
await twonlyDB.keyVerificationDao.insertVerificationToken(token);
|
await twonlyDB.keyVerificationDao.insertVerificationToken(token);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/compression.service.dart';
|
import 'package:twonly/src/services/mediafiles/compression.service.dart';
|
||||||
import 'package:twonly/src/services/mediafiles/thumbnail.service.dart';
|
import 'package:twonly/src/services/mediafiles/thumbnail.service.dart';
|
||||||
|
import 'package:twonly/src/services/memories/memories_cloud.service.dart'
|
||||||
|
show MemoriesCloudService;
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
|
|
@ -220,6 +222,7 @@ class MediaFileService {
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
if (mediaFile.stored &&
|
if (mediaFile.stored &&
|
||||||
|
mediaFile.cloudState == CloudState.none &&
|
||||||
mediaFile.createdAt.isBefore(
|
mediaFile.createdAt.isBefore(
|
||||||
clock.now().subtract(const Duration(days: 30)),
|
clock.now().subtract(const Duration(days: 30)),
|
||||||
)) {
|
)) {
|
||||||
|
|
@ -335,6 +338,7 @@ class MediaFileService {
|
||||||
unawaited(createThumbnail());
|
unawaited(createThumbnail());
|
||||||
await calculateAndSaveSize();
|
await calculateAndSaveSize();
|
||||||
await hashMediaFile();
|
await hashMediaFile();
|
||||||
|
await MemoriesCloudService().checkUploads();
|
||||||
// updateFromDb is done in hashStoredMedia()
|
// updateFromDb is done in hashStoredMedia()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,7 @@ Future<bool> createThumbnailsForImage(
|
||||||
destinationFile.absolute.path,
|
destinationFile.absolute.path,
|
||||||
minWidth: 300,
|
minWidth: 300,
|
||||||
minHeight: 300,
|
minHeight: 300,
|
||||||
quality: 100,
|
quality: 50,
|
||||||
format: CompressFormat.webp,
|
format: CompressFormat.webp,
|
||||||
);
|
);
|
||||||
stopwatch.stop();
|
stopwatch.stop();
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:blurhash_dart/blurhash_dart.dart' as bh;
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:image/image.dart' as img;
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
|
|
@ -190,7 +193,8 @@ class MemoriesService {
|
||||||
|
|
||||||
return MemoriesState(
|
return MemoriesState(
|
||||||
filesToMigrate: filesToMigrate,
|
filesToMigrate: filesToMigrate,
|
||||||
totalFilesToMigrate: filesToMigrate, // Reset total when computing new state? No, keep existing total if migrating.
|
totalFilesToMigrate:
|
||||||
|
filesToMigrate, // Reset total when computing new state? No, keep existing total if migrating.
|
||||||
galleryItems: tempGalleryItems,
|
galleryItems: tempGalleryItems,
|
||||||
months: tempMonths,
|
months: tempMonths,
|
||||||
orderedByMonth: tempOrderedByMonth,
|
orderedByMonth: tempOrderedByMonth,
|
||||||
|
|
@ -256,7 +260,12 @@ class MemoriesService {
|
||||||
// Phase 2: Background — hash, crop analysis, size calculation.
|
// Phase 2: Background — hash, crop analysis, size calculation.
|
||||||
// Each DB write here fires the stream subscription above, keeping
|
// Each DB write here fires the stream subscription above, keeping
|
||||||
// the gallery state fresh without a separate notification step.
|
// the gallery state fresh without a separate notification step.
|
||||||
await _backgroundProcessPendingFiles(pendingFiles);
|
_dbSubscription?.pause();
|
||||||
|
try {
|
||||||
|
await _backgroundProcessPendingFiles(pendingFiles);
|
||||||
|
} finally {
|
||||||
|
_dbSubscription?.resume();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error('Error in background migration queue: $e');
|
Log.error('Error in background migration queue: $e');
|
||||||
}
|
}
|
||||||
|
|
@ -280,6 +289,26 @@ class MemoriesService {
|
||||||
if (mediaService.mediaFile.sizeInBytes == null) {
|
if (mediaService.mediaFile.sizeInBytes == null) {
|
||||||
await mediaService.calculateAndSaveSize();
|
await mediaService.calculateAndSaveSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mediaService.mediaFile.blurhash == null) {
|
||||||
|
try {
|
||||||
|
final imageFile = mediaService.thumbnailPath.existsSync()
|
||||||
|
? mediaService.thumbnailPath
|
||||||
|
: mediaService.originalPath;
|
||||||
|
if (imageFile.existsSync()) {
|
||||||
|
final bytes = await imageFile.readAsBytes();
|
||||||
|
final blurhash = await compute(_calculateBlurhash, bytes);
|
||||||
|
if (blurhash != null) {
|
||||||
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
MediaFilesCompanion(blurhash: Value(blurhash)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Error generating blurhash for ${mediaFile.mediaId}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.error(
|
Log.error(
|
||||||
'Error in background processing of ${mediaFile.mediaId}: $e',
|
'Error in background processing of ${mediaFile.mediaId}: $e',
|
||||||
|
|
@ -360,3 +389,13 @@ class MemoriesService {
|
||||||
_stateController.close();
|
_stateController.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _calculateBlurhash(Uint8List bytes) {
|
||||||
|
try {
|
||||||
|
final image = img.decodeImage(bytes);
|
||||||
|
if (image != null) {
|
||||||
|
return bh.BlurHash.encode(image).hash;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
|
||||||
413
lib/src/services/memories/memories_cloud.service.dart
Normal file
413
lib/src/services/memories/memories_cloud.service.dart
Normal file
|
|
@ -0,0 +1,413 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cryptography_flutter_plus/cryptography_flutter_plus.dart';
|
||||||
|
import 'package:cryptography_plus/cryptography_plus.dart';
|
||||||
|
import 'package:drift/drift.dart' show Value;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:mutex/mutex.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
|
||||||
|
as server;
|
||||||
|
import 'package:twonly/src/model/protobuf/client/generated/backup.pb.dart';
|
||||||
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
|
class MemoriesBackupProgress {
|
||||||
|
MemoriesBackupProgress({
|
||||||
|
required this.totalPending,
|
||||||
|
required this.currentUploaded,
|
||||||
|
required this.currentUploadProgress,
|
||||||
|
this.currentMediaId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int totalPending;
|
||||||
|
final int currentUploaded;
|
||||||
|
final double currentUploadProgress;
|
||||||
|
final String? currentMediaId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProgressMultipartRequest extends http.MultipartRequest {
|
||||||
|
ProgressMultipartRequest(super.method, super.url, {this.onProgress});
|
||||||
|
|
||||||
|
final void Function(int bytes, int totalBytes)? onProgress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
http.ByteStream finalize() {
|
||||||
|
final byteStream = super.finalize();
|
||||||
|
if (onProgress == null) return byteStream;
|
||||||
|
|
||||||
|
final total = contentLength;
|
||||||
|
var bytes = 0;
|
||||||
|
|
||||||
|
final transformer = StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||||
|
handleData: (data, sink) {
|
||||||
|
bytes += data.length;
|
||||||
|
onProgress!(bytes, total);
|
||||||
|
sink.add(data);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return http.ByteStream(byteStream.transform(transformer));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MemoriesCloudService {
|
||||||
|
static final Map<String, Mutex> _fileMutexes = {};
|
||||||
|
|
||||||
|
Timer? _timer;
|
||||||
|
bool _isProcessing = false;
|
||||||
|
bool get isProcessing => _isProcessing;
|
||||||
|
|
||||||
|
final _progressController =
|
||||||
|
StreamController<MemoriesBackupProgress?>.broadcast();
|
||||||
|
Stream<MemoriesBackupProgress?> get progressStream =>
|
||||||
|
_progressController.stream;
|
||||||
|
|
||||||
|
MemoriesBackupProgress? _currentProgress;
|
||||||
|
MemoriesBackupProgress? get currentProgress => _currentProgress;
|
||||||
|
|
||||||
|
void init() {
|
||||||
|
_timer = Timer.periodic(const Duration(minutes: 5), (_) {
|
||||||
|
checkUploads();
|
||||||
|
});
|
||||||
|
// Run immediately
|
||||||
|
Future.delayed(const Duration(seconds: 10), checkUploads);
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_timer?.cancel();
|
||||||
|
_progressController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateProgress({
|
||||||
|
required int totalPending,
|
||||||
|
required int currentUploaded,
|
||||||
|
required double currentUploadProgress,
|
||||||
|
String? currentMediaId,
|
||||||
|
}) {
|
||||||
|
_currentProgress = MemoriesBackupProgress(
|
||||||
|
totalPending: totalPending,
|
||||||
|
currentUploaded: currentUploaded,
|
||||||
|
currentUploadProgress: currentUploadProgress,
|
||||||
|
currentMediaId: currentMediaId,
|
||||||
|
);
|
||||||
|
_progressController.add(_currentProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> checkUploads() async {
|
||||||
|
if (_isProcessing || !userService.currentUser.isCloudBackupEnabled) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final memories = await twonlyDB.mediaFilesDao.getMemoriesToBackup();
|
||||||
|
if (memories.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isProcessing = true;
|
||||||
|
|
||||||
|
final total = memories.length;
|
||||||
|
var uploaded = 0;
|
||||||
|
|
||||||
|
_updateProgress(
|
||||||
|
totalPending: total,
|
||||||
|
currentUploaded: uploaded,
|
||||||
|
currentUploadProgress: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final mediaFile in memories) {
|
||||||
|
_updateProgress(
|
||||||
|
totalPending: total,
|
||||||
|
currentUploaded: uploaded,
|
||||||
|
currentUploadProgress: 0,
|
||||||
|
currentMediaId: mediaFile.mediaId,
|
||||||
|
);
|
||||||
|
|
||||||
|
final success = await _backupMemory(mediaFile, (progress) {
|
||||||
|
_updateProgress(
|
||||||
|
totalPending: total,
|
||||||
|
currentUploaded: uploaded,
|
||||||
|
currentUploadProgress: progress,
|
||||||
|
currentMediaId: mediaFile.mediaId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
uploaded++;
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateProgress(
|
||||||
|
totalPending: total,
|
||||||
|
currentUploaded: uploaded,
|
||||||
|
currentUploadProgress: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Error in MemoriesCloudService.checkUploads: $e');
|
||||||
|
} finally {
|
||||||
|
_currentProgress = null;
|
||||||
|
_isProcessing = false;
|
||||||
|
_progressController.add(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> downloadFromCloud(
|
||||||
|
MediaFileService media, {
|
||||||
|
required bool isThumbnail,
|
||||||
|
}) async {
|
||||||
|
final mediaId = media.mediaFile.mediaId;
|
||||||
|
final mutex = _fileMutexes.putIfAbsent(mediaId, Mutex.new);
|
||||||
|
|
||||||
|
return mutex.protect(() async {
|
||||||
|
final targetPath = isThumbnail ? media.thumbnailPath : media.storedPath;
|
||||||
|
|
||||||
|
if (targetPath.existsSync() && targetPath.lengthSync() > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final urls = await apiService.getMemoriesUrl(mediaId, isThumbnail);
|
||||||
|
if (urls == null || !urls.hasFullDownloadUrl()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http.get(Uri.parse(urls.fullDownloadUrl));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return await _decryptFile(response.bodyBytes, targetPath);
|
||||||
|
} else {
|
||||||
|
Log.warn(
|
||||||
|
'Failed to download ${isThumbnail ? 'thumbnail' : 'full media'} statuscode ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.warn(e);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _backupMemory(
|
||||||
|
MediaFile mediaFile,
|
||||||
|
void Function(double progress) onProgress,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final ms = MediaFileService(mediaFile);
|
||||||
|
|
||||||
|
if (!ms.storedPath.existsSync()) return false;
|
||||||
|
|
||||||
|
if (!mediaFile.hasThumbnail) {
|
||||||
|
await MediaFileService(mediaFile).createThumbnail();
|
||||||
|
}
|
||||||
|
|
||||||
|
final sizeBytes = ms.storedPath.lengthSync();
|
||||||
|
final urls = await apiService.requestMemoriesUpload(
|
||||||
|
sizeBytes,
|
||||||
|
mediaFile.createdAt,
|
||||||
|
mediaFile.mediaId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (urls == null) {
|
||||||
|
Log.error('Could not get upload URLs for memory ${mediaFile.mediaId}');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
const MediaFilesCompanion(
|
||||||
|
cloudState: Value(CloudState.pending),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final mediaKey = getRandomUint8List(32);
|
||||||
|
final encryptedMediaKey = await RustKeyManager.encryptCloudMediaKey(
|
||||||
|
mediaKey: mediaKey,
|
||||||
|
addition: 'app',
|
||||||
|
);
|
||||||
|
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
|
||||||
|
// 1. Upload thumbnail if exists
|
||||||
|
if (ms.thumbnailPath.existsSync() && urls.hasThumbnailUpload()) {
|
||||||
|
final thumbFile = await _encryptFile(
|
||||||
|
ms.thumbnailPath,
|
||||||
|
mediaKey,
|
||||||
|
encryptedMediaKey,
|
||||||
|
tempDir,
|
||||||
|
'thumb_${mediaFile.mediaId}',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _uploadToS3(urls.thumbnailUpload, thumbFile, (_) {});
|
||||||
|
} finally {
|
||||||
|
if (thumbFile.existsSync()) {
|
||||||
|
thumbFile.deleteSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Upload full media if exists
|
||||||
|
if (urls.hasFullUpload()) {
|
||||||
|
final fullFile = await _encryptFile(
|
||||||
|
ms.storedPath,
|
||||||
|
mediaKey,
|
||||||
|
encryptedMediaKey,
|
||||||
|
tempDir,
|
||||||
|
'full_${mediaFile.mediaId}',
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await _uploadToS3(urls.fullUpload, fullFile, onProgress);
|
||||||
|
} finally {
|
||||||
|
if (fullFile.existsSync()) {
|
||||||
|
fullFile.deleteSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Confirm upload
|
||||||
|
final confirmRes = await apiService.confirmMemoriesUpload(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
);
|
||||||
|
if (confirmRes.isSuccess) {
|
||||||
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
const MediaFilesCompanion(
|
||||||
|
cloudState: Value(CloudState.uploaded),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Log.info(
|
||||||
|
'Cloud backup complete and confirmed for ${mediaFile.mediaId}',
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
throw Exception('Server confirmation failed: ${confirmRes.error}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Error backing up memory ${mediaFile.mediaId}: $e');
|
||||||
|
await twonlyDB.mediaFilesDao.updateMedia(
|
||||||
|
mediaFile.mediaId,
|
||||||
|
const MediaFilesCompanion(
|
||||||
|
cloudState: Value(CloudState.none),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _uploadToS3(
|
||||||
|
server.Response_PresignedPost presignedPost,
|
||||||
|
File file,
|
||||||
|
void Function(double progress) onProgress,
|
||||||
|
) async {
|
||||||
|
final request = ProgressMultipartRequest(
|
||||||
|
'POST',
|
||||||
|
Uri.parse(presignedPost.url),
|
||||||
|
onProgress: (bytes, total) {
|
||||||
|
if (total > 0) {
|
||||||
|
onProgress(bytes / total);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
request.fields.addAll(presignedPost.fields);
|
||||||
|
request.files.add(
|
||||||
|
await http.MultipartFile.fromPath(
|
||||||
|
'file',
|
||||||
|
file.path,
|
||||||
|
filename: 'file',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final streamedResponse = await request.send();
|
||||||
|
final response = await http.Response.fromStream(streamedResponse);
|
||||||
|
if (response.statusCode != 200 && response.statusCode != 204) {
|
||||||
|
throw Exception(
|
||||||
|
'S3 upload failed: ${response.statusCode} - ${response.body}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<File> _encryptFile(
|
||||||
|
File inputFile,
|
||||||
|
Uint8List mediaKey,
|
||||||
|
List<int> encryptedMediaKey,
|
||||||
|
Directory tempDir,
|
||||||
|
String prefix,
|
||||||
|
) async {
|
||||||
|
final dataToEncrypt = await inputFile.readAsBytes();
|
||||||
|
final chacha20 = FlutterChacha20.poly1305Aead();
|
||||||
|
final nonce = chacha20.newNonce();
|
||||||
|
|
||||||
|
final secretBox = await chacha20.encrypt(
|
||||||
|
dataToEncrypt,
|
||||||
|
secretKey: SecretKey(mediaKey),
|
||||||
|
nonce: nonce,
|
||||||
|
);
|
||||||
|
|
||||||
|
final cipherTextWithMac = Uint8List.fromList(
|
||||||
|
secretBox.cipherText + secretBox.mac.bytes,
|
||||||
|
);
|
||||||
|
|
||||||
|
final payload = CloudMediaBackupEncrypted()
|
||||||
|
..addition = 'app'
|
||||||
|
..encryptedMediaKey = encryptedMediaKey
|
||||||
|
..mediaNonce = nonce
|
||||||
|
..mediaCiphertext = cipherTextWithMac;
|
||||||
|
|
||||||
|
final outFile = File('${tempDir.path}/$prefix.encrypted');
|
||||||
|
await outFile.writeAsBytes(payload.writeToBuffer());
|
||||||
|
return outFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<bool> _decryptFile(
|
||||||
|
Uint8List encryptedData,
|
||||||
|
File outFile,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
// 2. Parse the protobuf payload FIRST to access the key and addition
|
||||||
|
final payload = CloudMediaBackupEncrypted.fromBuffer(encryptedData);
|
||||||
|
|
||||||
|
// 3. Get the media key using the encrypted key and addition from the payload
|
||||||
|
final mediaKey = await RustKeyManager.decryptCloudMediaKey(
|
||||||
|
encryptedMediaKey: payload.encryptedMediaKey,
|
||||||
|
addition: payload.addition,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Extract the concatenated ciphertext + MAC, and the nonce
|
||||||
|
final nonce = payload.mediaNonce;
|
||||||
|
final cipherTextWithMac = payload.mediaCiphertext;
|
||||||
|
|
||||||
|
// 5. Separate the ciphertext and the MAC (Poly1305 MAC is always 16 bytes)
|
||||||
|
const macLength = 16;
|
||||||
|
final cipherTextLength = cipherTextWithMac.length - macLength;
|
||||||
|
|
||||||
|
final cipherText = cipherTextWithMac.sublist(0, cipherTextLength);
|
||||||
|
final macBytes = cipherTextWithMac.sublist(cipherTextLength);
|
||||||
|
|
||||||
|
// 6. Reconstruct the SecretBox
|
||||||
|
final secretBox = SecretBox(
|
||||||
|
cipherText,
|
||||||
|
nonce: nonce,
|
||||||
|
mac: Mac(macBytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Decrypt the data using the newly retrieved media key
|
||||||
|
final chacha20 = FlutterChacha20.poly1305Aead();
|
||||||
|
final decryptedBytes = await chacha20.decrypt(
|
||||||
|
secretBox,
|
||||||
|
secretKey: SecretKey(mediaKey),
|
||||||
|
);
|
||||||
|
|
||||||
|
await outFile.writeAsBytes(decryptedBytes);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
Log.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final memoriesCloudService = MemoriesCloudService();
|
||||||
|
|
@ -13,6 +13,7 @@ import 'package:twonly/src/database/tables/contacts.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/model/json/signal_identity.model.dart';
|
import 'package:twonly/src/model/json/signal_identity.model.dart';
|
||||||
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
|
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
|
||||||
|
import 'package:twonly/src/services/passwordless_recovery.service.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
import 'package:twonly/src/services/user_discovery.service.dart';
|
import 'package:twonly/src/services/user_discovery.service.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
@ -170,9 +171,14 @@ Future<void> runMigrations() async {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (userService.currentUser.appVersion < 118) {
|
||||||
|
await PasswordlessRecoveryService.migratePasswordlessRecovery();
|
||||||
|
await UserService.update((u) => u.appVersion = 118);
|
||||||
|
}
|
||||||
|
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
assert(
|
assert(
|
||||||
AppState.latestAppVersionId == 117,
|
AppState.latestAppVersionId == 118,
|
||||||
'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.',
|
'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.',
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
|
|
|
||||||
302
lib/src/services/news.service.dart
Normal file
302
lib/src/services/news.service.dart
Normal file
|
|
@ -0,0 +1,302 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' as ui;
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:path/path.dart';
|
||||||
|
import 'package:twonly/globals.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
|
class BlogEntry {
|
||||||
|
BlogEntry({
|
||||||
|
required this.title,
|
||||||
|
required this.link,
|
||||||
|
required this.guid,
|
||||||
|
required this.description,
|
||||||
|
required this.imageUrl,
|
||||||
|
this.pubDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BlogEntry.fromJson(Map<String, dynamic> json) => BlogEntry(
|
||||||
|
title: (json['title'] as String?) ?? '',
|
||||||
|
link: (json['link'] as String?) ?? '',
|
||||||
|
guid: (json['guid'] as String?) ?? '',
|
||||||
|
description: (json['description'] as String?) ?? '',
|
||||||
|
imageUrl: (json['imageUrl'] as String?) ?? '',
|
||||||
|
pubDate: json['pubDate'] != null
|
||||||
|
? DateTime.tryParse(json['pubDate'] as String)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String link;
|
||||||
|
final String guid;
|
||||||
|
final String description;
|
||||||
|
final String imageUrl;
|
||||||
|
final DateTime? pubDate;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'title': title,
|
||||||
|
'link': link,
|
||||||
|
'guid': guid,
|
||||||
|
'description': description,
|
||||||
|
'imageUrl': imageUrl,
|
||||||
|
'pubDate': pubDate?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'BlogEntry(title: $title, link: $link, guid: $guid, description: $description, imageUrl: $imageUrl, pubDate: $pubDate)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class NewsService {
|
||||||
|
List<BlogEntry> entries = [];
|
||||||
|
Set<String> openedGuids = {};
|
||||||
|
DateTime? lastDownloadedAt;
|
||||||
|
bool hasLoadedBefore = false;
|
||||||
|
|
||||||
|
final ValueNotifier<int> unreadCountNotifier = ValueNotifier<int>(0);
|
||||||
|
|
||||||
|
File get _cacheFile =>
|
||||||
|
File(join(AppEnvironment.supportDir, 'news_feed.json'));
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
try {
|
||||||
|
await loadCache();
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Failed to load news cache: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadCache() async {
|
||||||
|
final file = _cacheFile;
|
||||||
|
if (!file.existsSync()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
hasLoadedBefore = json['hasLoadedBefore'] as bool? ?? false;
|
||||||
|
|
||||||
|
final lastDownloadedStr = json['lastDownloadedAt'] as String?;
|
||||||
|
if (lastDownloadedStr != null) {
|
||||||
|
lastDownloadedAt = DateTime.tryParse(lastDownloadedStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
final openedList = json['openedGuids'] as List<dynamic>? ?? [];
|
||||||
|
openedGuids = openedList.map((e) => e.toString()).toSet();
|
||||||
|
|
||||||
|
final entriesList = json['entries'] as List<dynamic>? ?? [];
|
||||||
|
entries = entriesList
|
||||||
|
.map((e) => BlogEntry.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
_updateUnreadCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveCache() async {
|
||||||
|
final file = _cacheFile;
|
||||||
|
final json = {
|
||||||
|
'hasLoadedBefore': hasLoadedBefore,
|
||||||
|
'lastDownloadedAt': lastDownloadedAt?.toIso8601String(),
|
||||||
|
'openedGuids': openedGuids.toList(),
|
||||||
|
'entries': entries.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
|
await file.writeAsString(jsonEncode(json));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateUnreadCount() {
|
||||||
|
var count = 0;
|
||||||
|
for (final entry in entries) {
|
||||||
|
if (!openedGuids.contains(entry.guid)) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unreadCountNotifier.value = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markAllAsRead() async {
|
||||||
|
for (final entry in entries) {
|
||||||
|
openedGuids.add(entry.guid);
|
||||||
|
}
|
||||||
|
_updateUnreadCount();
|
||||||
|
await saveCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchFeed({http.Client? client, bool force = false}) async {
|
||||||
|
try {
|
||||||
|
final lang = ui.PlatformDispatcher.instance.locale.languageCode;
|
||||||
|
final url = lang == 'de'
|
||||||
|
? 'https://twonly.eu/de/blog/rss.xml'
|
||||||
|
: 'https://twonly.eu/en/blog/rss.xml';
|
||||||
|
|
||||||
|
final response = client != null
|
||||||
|
? await client.get(Uri.parse(url))
|
||||||
|
: await http.get(Uri.parse(url));
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
Log.error('Failed to download RSS feed: code ${response.statusCode}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final xml = utf8.decode(response.bodyBytes);
|
||||||
|
final newEntries = _parseRss(xml);
|
||||||
|
|
||||||
|
if (!hasLoadedBefore) {
|
||||||
|
// First run: mark all fetched as already opened
|
||||||
|
for (final entry in newEntries) {
|
||||||
|
openedGuids.add(entry.guid);
|
||||||
|
}
|
||||||
|
hasLoadedBefore = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
entries = newEntries;
|
||||||
|
lastDownloadedAt = DateTime.now();
|
||||||
|
|
||||||
|
_updateUnreadCount();
|
||||||
|
await saveCache();
|
||||||
|
Log.info(
|
||||||
|
'Successfully fetched ${entries.length} news entries. Unread count: ${unreadCountNotifier.value}',
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Failed to fetch news feed: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BlogEntry> _parseRss(String xml) {
|
||||||
|
final entries = <BlogEntry>[];
|
||||||
|
final itemRegex = RegExp(r'<item>([\s\S]*?)<\/item>');
|
||||||
|
final matches = itemRegex.allMatches(xml);
|
||||||
|
|
||||||
|
for (final match in matches) {
|
||||||
|
final itemContent = match.group(1) ?? '';
|
||||||
|
|
||||||
|
final titleMatch = RegExp(
|
||||||
|
r'<title>([\s\S]*?)<\/title>',
|
||||||
|
).firstMatch(itemContent);
|
||||||
|
final title = _stripCdata(titleMatch?.group(1) ?? '');
|
||||||
|
|
||||||
|
final linkMatch = RegExp(
|
||||||
|
r'<link>([\s\S]*?)<\/link>',
|
||||||
|
).firstMatch(itemContent);
|
||||||
|
final link = _stripCdata(linkMatch?.group(1) ?? '');
|
||||||
|
|
||||||
|
final guidMatch = RegExp(
|
||||||
|
r'<guid[^>]*?>([\s\S]*?)<\/guid>',
|
||||||
|
).firstMatch(itemContent);
|
||||||
|
final guid = _stripCdata(guidMatch?.group(1) ?? link);
|
||||||
|
|
||||||
|
final descMatch = RegExp(
|
||||||
|
r'<description>([\s\S]*?)<\/description>',
|
||||||
|
).firstMatch(itemContent);
|
||||||
|
final descriptionRaw = _stripCdata(descMatch?.group(1) ?? '');
|
||||||
|
final description = descriptionRaw
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(''', "'")
|
||||||
|
.replaceAll(RegExp('<[^>]*>'), '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
var imageUrl = '';
|
||||||
|
final enclosureMatch = RegExp(
|
||||||
|
r'<enclosure\s+[^>]*?url="([^"]+)"',
|
||||||
|
).firstMatch(itemContent);
|
||||||
|
if (enclosureMatch != null) {
|
||||||
|
imageUrl = enclosureMatch.group(1) ?? '';
|
||||||
|
}
|
||||||
|
if (imageUrl.isEmpty) {
|
||||||
|
final mediaMatch = RegExp(
|
||||||
|
r'<media:content\s+[^>]*?url="([^"]+)"',
|
||||||
|
).firstMatch(itemContent);
|
||||||
|
if (mediaMatch != null) {
|
||||||
|
imageUrl = mediaMatch.group(1) ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pubDateMatch = RegExp(
|
||||||
|
r'<pubDate>([\s\S]*?)<\/pubDate>',
|
||||||
|
).firstMatch(itemContent);
|
||||||
|
DateTime? pubDate;
|
||||||
|
if (pubDateMatch != null) {
|
||||||
|
final pubDateStr = pubDateMatch.group(1) ?? '';
|
||||||
|
pubDate = DateTime.tryParse(pubDateStr);
|
||||||
|
pubDate ??= _parseRfc2822(pubDateStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.add(
|
||||||
|
BlogEntry(
|
||||||
|
title: title,
|
||||||
|
link: link,
|
||||||
|
guid: guid,
|
||||||
|
description: description,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
pubDate: pubDate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stripCdata(String input) {
|
||||||
|
var s = input.trim();
|
||||||
|
if (s.startsWith('<![CDATA[')) {
|
||||||
|
s = s.substring(9);
|
||||||
|
}
|
||||||
|
if (s.endsWith(']]>')) {
|
||||||
|
s = s.substring(0, s.length - 3);
|
||||||
|
}
|
||||||
|
return s.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? _parseRfc2822(String dateString) {
|
||||||
|
try {
|
||||||
|
var cleaned = dateString.trim();
|
||||||
|
if (cleaned.contains(',')) {
|
||||||
|
cleaned = cleaned.split(',')[1].trim();
|
||||||
|
}
|
||||||
|
final parts = cleaned.split(RegExp(r'\s+'));
|
||||||
|
if (parts.length < 4) return null;
|
||||||
|
|
||||||
|
final day = int.tryParse(parts[0]) ?? 1;
|
||||||
|
final monthStr = parts[1].toLowerCase();
|
||||||
|
final year = int.tryParse(parts[2]) ?? DateTime.now().year;
|
||||||
|
|
||||||
|
final timeParts = parts[3].split(':');
|
||||||
|
final hour = timeParts.isNotEmpty ? (int.tryParse(timeParts[0]) ?? 0) : 0;
|
||||||
|
final minute = timeParts.length > 1
|
||||||
|
? (int.tryParse(timeParts[1]) ?? 0)
|
||||||
|
: 0;
|
||||||
|
final second = timeParts.length > 2
|
||||||
|
? (int.tryParse(timeParts[2]) ?? 0)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const months = {
|
||||||
|
'jan': 1,
|
||||||
|
'feb': 2,
|
||||||
|
'mar': 3,
|
||||||
|
'apr': 4,
|
||||||
|
'may': 5,
|
||||||
|
'jun': 6,
|
||||||
|
'jul': 7,
|
||||||
|
'aug': 8,
|
||||||
|
'sep': 9,
|
||||||
|
'oct': 10,
|
||||||
|
'nov': 11,
|
||||||
|
'dec': 12,
|
||||||
|
};
|
||||||
|
final monthStringPart = monthStr.length > 3
|
||||||
|
? monthStr.substring(0, 3)
|
||||||
|
: monthStr;
|
||||||
|
final month = months[monthStringPart] ?? 1;
|
||||||
|
|
||||||
|
return DateTime.utc(year, month, day, hour, minute, second);
|
||||||
|
} catch (e) {
|
||||||
|
Log.warn('Failed to parse date: $e');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,15 +5,13 @@ import 'package:clock/clock.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:crypto/crypto.dart' hide Hmac;
|
import 'package:crypto/crypto.dart' hide Hmac;
|
||||||
import 'package:cryptography_plus/cryptography_plus.dart'
|
import 'package:cryptography_plus/cryptography_plus.dart'
|
||||||
show Hmac, Mac, SecretBox, SecretKey, Xchacha20;
|
show Hkdf, Hmac, Mac, SecretBox, SecretKey, Xchacha20;
|
||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.dart';
|
||||||
import 'package:fixnum/fixnum.dart';
|
import 'package:fixnum/fixnum.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:twonly/core/bridge/wrapper.dart';
|
import 'package:twonly/core/bridge/wrapper.dart';
|
||||||
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart'
|
import 'package:twonly/src/database/daos/contacts.dao.dart'
|
||||||
show getContactDisplayName;
|
show getContactDisplayName;
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
|
@ -26,7 +24,7 @@ import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery
|
||||||
import 'package:twonly/src/providers/routing.provider.dart';
|
import 'package:twonly/src/providers/routing.provider.dart';
|
||||||
import 'package:twonly/src/services/api/messages.api.dart';
|
import 'package:twonly/src/services/api/messages.api.dart';
|
||||||
import 'package:twonly/src/services/user.service.dart';
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
import 'package:twonly/src/utils/avatars.dart' show getAvatarSvg;
|
|
||||||
import 'package:twonly/src/utils/keyvalue.dart';
|
import 'package:twonly/src/utils/keyvalue.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
@ -38,6 +36,8 @@ class PasswordlessRecoveryService {
|
||||||
static final StreamController<String> onEmailTokenReceived =
|
static final StreamController<String> onEmailTokenReceived =
|
||||||
StreamController<String>.broadcast();
|
StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
static String? lastEmailToken;
|
||||||
|
|
||||||
static String linkPrefix = 'https://me.twonly.eu/r/#';
|
static String linkPrefix = 'https://me.twonly.eu/r/#';
|
||||||
|
|
||||||
static final Set<String> _handledNotificationIds = {};
|
static final Set<String> _handledNotificationIds = {};
|
||||||
|
|
@ -50,11 +50,8 @@ class PasswordlessRecoveryService {
|
||||||
final parts = fragment.split('/');
|
final parts = fragment.split('/');
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) {
|
||||||
if (fragment.isNotEmpty) {
|
if (fragment.isNotEmpty) {
|
||||||
|
lastEmailToken = fragment;
|
||||||
onEmailTokenReceived.add(fragment);
|
onEmailTokenReceived.add(fragment);
|
||||||
final context = rootNavigatorKey.currentContext;
|
|
||||||
if (context != null && context.mounted) {
|
|
||||||
unawaited(context.push(Routes.recoverPasswordless, extra: fragment));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -108,9 +105,7 @@ class PasswordlessRecoveryService {
|
||||||
final shareUser = TrustedFriendShare_User(
|
final shareUser = TrustedFriendShare_User(
|
||||||
userId: Int64(contact.userId),
|
userId: Int64(contact.userId),
|
||||||
displayName: getContactDisplayName(contact),
|
displayName: getContactDisplayName(contact),
|
||||||
avatar: contact.avatarSvgCompressed != null
|
avatar: contact.avatarSvgCompressed,
|
||||||
? utf8.encode(getAvatarSvg(contact.avatarSvgCompressed!))
|
|
||||||
: null,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final trustedFriendShare = TrustedFriendShare(
|
final trustedFriendShare = TrustedFriendShare(
|
||||||
|
|
@ -190,52 +185,45 @@ class PasswordlessRecoveryService {
|
||||||
|
|
||||||
switch (secondFactorType) {
|
switch (secondFactorType) {
|
||||||
case SecondFactorType.email:
|
case SecondFactorType.email:
|
||||||
|
config.email = secondFactorValue.toLowerCase();
|
||||||
// The serverKey is encrypted with the email; this protects the server from seeing the user's email address, while
|
emailHint = createEmailHint(config.email!);
|
||||||
// also ensuring that the server can only send the real secret to the user's configured email, as a different
|
|
||||||
// email will result in a different secret key.
|
|
||||||
// This is only stored so the user can see there email, and verify that he has set the valid mail...
|
|
||||||
config.email = secondFactorValue;
|
|
||||||
emailHint = createEmailHint(secondFactorValue);
|
|
||||||
|
|
||||||
// E-Mail Protection:
|
// E-Mail Protection:
|
||||||
// - Server can only learn the email during recovery. Ensured as the server gets the NONCE and MAC to decrpyt during recovery.
|
// - Server can only learn the email during recovery. Ensured as the server gets the NONCE to decrypt only during recovery.
|
||||||
// - Trusted-friends: Server key is send to the mail, they whould need access to the user's mail account.
|
// - Trusted-friends: Server key is only sent to the mail, they would need access to the user's mail account.
|
||||||
secondFactorEncryptedServerKeyKey = SecretKey(
|
config.serverKeyProtection = getRandomUint8List(32);
|
||||||
Uint8List.fromList(sha256.convert(utf8.encode(config.email!)).bytes),
|
|
||||||
|
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||||
|
secondFactorEncryptedServerKeyKey = await hkdf.deriveKey(
|
||||||
|
secretKey: SecretKey(config.serverKeyProtection!),
|
||||||
|
nonce: utf8.encode(config.email!),
|
||||||
);
|
);
|
||||||
|
|
||||||
case SecondFactorType.pin:
|
case SecondFactorType.pin:
|
||||||
|
|
||||||
// The pin seed - never shared with the server - ensures that the server is unable to brute-force real user's pin.
|
// The pin seed - never shared with the server - ensures that the server is unable to brute-force real user's pin
|
||||||
config.pinSeed = getRandomUint8List(32);
|
config.serverKeyProtection = getRandomUint8List(32);
|
||||||
|
|
||||||
// As the pin is heavily protected against brute-forcing e.g. will be deleted by the server after X tries, the
|
// As the pin is heavily protected against brute-forcing e.g. will be deleted by the server after 10 tries, the
|
||||||
// unlock token is required to prevent a malicous user (except the trusted friends) to triger this deletion.
|
// unlock token is required to prevent a malicious user (except the trusted friends) to trigger this deletion.
|
||||||
config.pinUnlockToken = getRandomUint8List(32);
|
config.pinUnlockToken = getRandomUint8List(32);
|
||||||
|
|
||||||
// Brute-force protection for the user's pin:
|
// Brute-force protection for the user's pin:
|
||||||
// - Server: Does not know the seed.
|
// - Server: Does not know the seed.
|
||||||
// - Trusted friends:
|
// - Trusted friends: Can only check the result 10 times before the server deletes the key. As they do not have
|
||||||
// Can only check the result X times before the server deletes the key. As they do not have
|
// the mac and the cipher text they are unable to brute-force the pin locally. And the server only allows 10 tries.
|
||||||
// the mac and the cypher text they are unable to brute-force the pin localy. And the server only allows 10
|
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||||
// tries.
|
secondFactorEncryptedServerKeyKey = await hkdf.deriveKey(
|
||||||
final pinProtectionKey = await Hmac.sha256().calculateMac(
|
secretKey: SecretKey(config.serverKeyProtection!),
|
||||||
Uint8List.fromList(utf8.encode(secondFactorValue)),
|
nonce: utf8.encode(secondFactorValue),
|
||||||
secretKey: SecretKey(config.pinSeed!),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// To restore the user has to provide the server with this encryption key. The server then can verify the
|
|
||||||
// correct pin was entered, when decrypting the server key as he also receives the mac and nonce from the user
|
|
||||||
// during recovery. Only when the mac is correct the server provides the user with the serverKey.
|
|
||||||
secondFactorEncryptedServerKeyKey = SecretKey(pinProtectionKey.bytes);
|
|
||||||
|
|
||||||
case SecondFactorType.none:
|
case SecondFactorType.none:
|
||||||
}
|
}
|
||||||
|
|
||||||
if (secondFactorEncryptedServerKeyKey != null) {
|
if (secondFactorEncryptedServerKeyKey != null) {
|
||||||
// The server key is used to encrypt the RecoveryData of the users. This ensures that when the trusted friends
|
// The server key is used to encrypt the RecoveryData of the users. This ensures that when the trusted friends
|
||||||
// colaberate, they additional need the serverKey to decrypt the user's key.
|
// collaborate, they additionally need the serverKey to decrypt the user's key.
|
||||||
serverKey = getRandomUint8List(32);
|
serverKey = getRandomUint8List(32);
|
||||||
|
|
||||||
final secretBox = await xchacha20.encrypt(
|
final secretBox = await xchacha20.encrypt(
|
||||||
|
|
@ -244,18 +232,16 @@ class PasswordlessRecoveryService {
|
||||||
nonce: xchacha20.newNonce(),
|
nonce: xchacha20.newNonce(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The server only gets the encrypted server key and the mac. Because the server does not know the nonce (192-bit
|
// The server only gets the encrypted server key, the mac, and the nonce.
|
||||||
// because of XChaCha), he is unable to decrypt the server key without the help of the trusted friends. This
|
// This ensures that the server never learns the user's original pin, as he is missing the serverKeyProtection and also unable to
|
||||||
// ensures that the server never learns the users orginal pin, as he is missing the pin_seed and also unable to
|
// brute-force the email of the user.
|
||||||
// brute-force the email of the user as he does not have the nonce.
|
|
||||||
encryptedServerKey = Uint8List.fromList([
|
encryptedServerKey = Uint8List.fromList([
|
||||||
...secretBox.cipherText,
|
...secretBox.cipherText,
|
||||||
...secretBox.mac.bytes,
|
...secretBox.mac.bytes,
|
||||||
|
...secretBox.nonce,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
config
|
config.encryptedServerKey = encryptedServerKey;
|
||||||
..encryptedServerKeyNonce = secretBox.nonce
|
|
||||||
..encryptedServerKey = encryptedServerKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Using shamir's secret to generate the shares for the users.
|
// 3. Using shamir's secret to generate the shares for the users.
|
||||||
|
|
@ -285,10 +271,9 @@ class PasswordlessRecoveryService {
|
||||||
|
|
||||||
final sharedSecretData = SharedSecretData(
|
final sharedSecretData = SharedSecretData(
|
||||||
recoveryData: recoveryData,
|
recoveryData: recoveryData,
|
||||||
pinSeed: config.pinSeed,
|
serverKeyProtection: config.serverKeyProtection,
|
||||||
pinUnlockToken: config.pinUnlockToken,
|
pinUnlockToken: config.pinUnlockToken,
|
||||||
emailHint: emailHint,
|
emailHint: emailHint,
|
||||||
encryptedServerKeyNonce: config.encryptedServerKeyNonce,
|
|
||||||
).writeToBuffer();
|
).writeToBuffer();
|
||||||
|
|
||||||
// 3.2. Use the amount of trusted friends to generate the shares
|
// 3.2. Use the amount of trusted friends to generate the shares
|
||||||
|
|
@ -326,35 +311,37 @@ class PasswordlessRecoveryService {
|
||||||
|
|
||||||
unawaited(performHeartbeat());
|
unawaited(performHeartbeat());
|
||||||
|
|
||||||
// The passwordless is configured sucessfully.
|
// The passwordless is configured successfully.
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<bool> testPin(String pin) async {
|
static Future<bool> testPin(String pin) async {
|
||||||
final config = userService.currentUser.passwordLessRecovery;
|
final config = userService.currentUser.passwordLessRecovery;
|
||||||
if (config?.pinSeed == null || config?.encryptedServerKey == null) {
|
if (config?.serverKeyProtection == null ||
|
||||||
|
config?.encryptedServerKey == null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final pinProtectionKey = await Hmac.sha256().calculateMac(
|
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||||
Uint8List.fromList(utf8.encode(pin)),
|
final secondFactorEncryptedServerKeyKey = await hkdf.deriveKey(
|
||||||
secretKey: SecretKey(config!.pinSeed!),
|
secretKey: SecretKey(config!.serverKeyProtection!),
|
||||||
);
|
nonce: utf8.encode(pin),
|
||||||
|
|
||||||
final secondFactorEncryptedServerKeyKey = SecretKey(
|
|
||||||
pinProtectionKey.bytes,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final xchacha20 = Xchacha20.poly1305Aead();
|
final xchacha20 = Xchacha20.poly1305Aead();
|
||||||
|
|
||||||
final combined = config.encryptedServerKey!;
|
final combined = config.encryptedServerKey!;
|
||||||
final cipherText = combined.sublist(0, combined.length - 16);
|
final nonceBytes = combined.sublist(combined.length - 24);
|
||||||
final macBytes = combined.sublist(combined.length - 16);
|
final macBytes = combined.sublist(
|
||||||
|
combined.length - 40,
|
||||||
|
combined.length - 24,
|
||||||
|
);
|
||||||
|
final cipherText = combined.sublist(0, combined.length - 40);
|
||||||
|
|
||||||
final secretBox = SecretBox(
|
final secretBox = SecretBox(
|
||||||
cipherText,
|
cipherText,
|
||||||
nonce: config.encryptedServerKeyNonce!,
|
nonce: nonceBytes,
|
||||||
mac: Mac(macBytes),
|
mac: Mac(macBytes),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -399,7 +386,7 @@ class PasswordlessRecoveryService {
|
||||||
clock.now().difference(lastContactHeartbeat).inHours >= 24;
|
clock.now().difference(lastContactHeartbeat).inHours >= 24;
|
||||||
|
|
||||||
if (isContactHeartbeatOlderThan24h) {
|
if (isContactHeartbeatOlderThan24h) {
|
||||||
// Get all contacts where recoveryLastHeartbeat is NULL. Then for each contacts send.
|
// Get all contacts where recoveryLastHeartbeat is NULL. Then for each contact send.
|
||||||
// recoveryLastHeartbeat is ONLY updated in case the contact has responded.
|
// recoveryLastHeartbeat is ONLY updated in case the contact has responded.
|
||||||
final pendingShares =
|
final pendingShares =
|
||||||
await (twonlyDB.select(twonlyDB.contacts)..where(
|
await (twonlyDB.select(twonlyDB.contacts)..where(
|
||||||
|
|
@ -545,7 +532,7 @@ class PasswordlessRecoveryService {
|
||||||
final recoveryLastHeartbeat =
|
final recoveryLastHeartbeat =
|
||||||
const ListEquality().equals(computedHash, msg.hash)
|
const ListEquality().equals(computedHash, msg.hash)
|
||||||
? clock.now()
|
? clock.now()
|
||||||
: null; // The stored share not valid (maybe a old backup was restored). This will cause the performHeartbeat to resend him his share
|
: null; // The stored share not valid (maybe an old backup was restored). This will cause the performHeartbeat to resend him his share
|
||||||
Log.info(
|
Log.info(
|
||||||
'[$receiptId] Got heartbeat: ($recoveryLastHeartbeat)',
|
'[$receiptId] Got heartbeat: ($recoveryLastHeartbeat)',
|
||||||
);
|
);
|
||||||
|
|
@ -605,6 +592,9 @@ class PasswordlessRecoveryService {
|
||||||
final receivedShare = ReceivedRecoveryShare(
|
final receivedShare = ReceivedRecoveryShare(
|
||||||
messageId: msgId,
|
messageId: msgId,
|
||||||
trustedFriendDisplayName: share.trustedFriend.displayName,
|
trustedFriendDisplayName: share.trustedFriend.displayName,
|
||||||
|
trustedFriendAvatarSvg: share.trustedFriend.hasAvatar()
|
||||||
|
? share.trustedFriend.avatar
|
||||||
|
: null,
|
||||||
myDisplayName: share.shareUser.displayName,
|
myDisplayName: share.shareUser.displayName,
|
||||||
myUserId: share.shareUser.userId.toInt(),
|
myUserId: share.shareUser.userId.toInt(),
|
||||||
myAvatarSvg: share.shareUser.hasAvatar()
|
myAvatarSvg: share.shareUser.hasAvatar()
|
||||||
|
|
@ -637,4 +627,53 @@ class PasswordlessRecoveryService {
|
||||||
|
|
||||||
return didUpdate;
|
return didUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<void> migratePasswordlessRecovery() async {
|
||||||
|
final config = userService.currentUser.passwordLessRecovery;
|
||||||
|
if (config == null) return;
|
||||||
|
|
||||||
|
final oldTrustedFriends = await (twonlyDB.select(
|
||||||
|
twonlyDB.contacts,
|
||||||
|
)..where((t) => t.recoveryIsTrustedFriend.equals(true))).get();
|
||||||
|
final trustedFriendIds = oldTrustedFriends.map((e) => e.userId).toList();
|
||||||
|
|
||||||
|
if (trustedFriendIds.isEmpty) return;
|
||||||
|
|
||||||
|
if (config.email != null) {
|
||||||
|
await enablePasswordlessRecovery(
|
||||||
|
trustedFriendIds: trustedFriendIds,
|
||||||
|
secondFactorType: SecondFactorType.email,
|
||||||
|
secondFactorValue: config.email!,
|
||||||
|
threshold: config.threshold,
|
||||||
|
);
|
||||||
|
} else if (config.pinUnlockToken == null) {
|
||||||
|
await enablePasswordlessRecovery(
|
||||||
|
trustedFriendIds: trustedFriendIds,
|
||||||
|
secondFactorType: SecondFactorType.none,
|
||||||
|
secondFactorValue: '',
|
||||||
|
threshold: config.threshold,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// It's PIN, we can't migrate it because we don't have the PIN. We delete it so the user has to do it again.
|
||||||
|
for (final contact in oldTrustedFriends) {
|
||||||
|
try {
|
||||||
|
await sendCipherText(
|
||||||
|
contact.userId,
|
||||||
|
pb.EncryptedContent(
|
||||||
|
passwordlessRecovery: pb.EncryptedContent_PasswordLessRecovery(
|
||||||
|
delete: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
Log.error(
|
||||||
|
'Failed to send delete PasswordLessRecovery message to contact ${contact.userId}: $e',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await twonlyDB.contactsDao.resetRecoveryDataForAllContacts();
|
||||||
|
await UserService.update((u) => u.passwordLessRecovery = null);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,51 @@ import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
||||||
|
const defaultAvatarSvg = '''
|
||||||
|
<!-- Taken from: https://getavataaars.com/ -->
|
||||||
|
<svg width="264px" height="280px" viewBox="0 0 264 280" version="1.1" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<defs>
|
||||||
|
<path
|
||||||
|
d="M124,144.610951 L124,163 L128,163 L128,163 C167.764502,163 200,195.235498 200,235 L200,244 L0,244 L0,235 C-4.86974701e-15,195.235498 32.235498,163 72,163 L72,163 L76,163 L76,144.610951 C58.7626345,136.422372 46.3722246,119.687011 44.3051388,99.8812385 C38.4803105,99.0577866 34,94.0521096 34,88 L34,74 C34,68.0540074 38.3245733,63.1180731 44,62.1659169 L44,56 L44,56 C44,25.072054 69.072054,5.68137151e-15 100,0 L100,0 L100,0 C130.927946,-5.68137151e-15 156,25.072054 156,56 L156,62.1659169 C161.675427,63.1180731 166,68.0540074 166,74 L166,88 C166,94.0521096 161.51969,99.0577866 155.694861,99.8812385 C153.627775,119.687011 141.237365,136.422372 124,144.610951 Z"
|
||||||
|
id="react-path-3"></path>
|
||||||
|
</defs>
|
||||||
|
<g id="Avataaar" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g transform="translate(-825.000000, -1100.000000)">
|
||||||
|
<g transform="translate(825.000000, 1100.000000)">
|
||||||
|
<g id="Avataaar" stroke-width="1" fill-rule="evenodd">
|
||||||
|
<g id="Body" transform="translate(32.000000, 36.000000)">
|
||||||
|
<mask id="react-mask-6" fill="white">
|
||||||
|
<use xlink:href="#react-path-3"></use>
|
||||||
|
</mask>
|
||||||
|
<g id="Skin/👶🏽-03-Brown" mask="url(#react-mask-6)" fill="#57CC99">
|
||||||
|
<g transform="translate(0.000000, 0.000000)" id="Color">
|
||||||
|
<rect x="0" y="0" width="264" height="280"></rect>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>''';
|
||||||
|
|
||||||
String getAvatarSvg(Uint8List avatarSvgCompressed) {
|
String getAvatarSvg(Uint8List avatarSvgCompressed) {
|
||||||
return utf8.decode(gzip.decode(avatarSvgCompressed));
|
try {
|
||||||
|
// Check for GZIP magic bytes (0x1F 0x8B)
|
||||||
|
final isGzip =
|
||||||
|
avatarSvgCompressed.length >= 2 &&
|
||||||
|
avatarSvgCompressed[0] == 0x1F &&
|
||||||
|
avatarSvgCompressed[1] == 0x8B;
|
||||||
|
|
||||||
|
final decodedBytes = isGzip
|
||||||
|
? gzip.decode(avatarSvgCompressed)
|
||||||
|
: avatarSvgCompressed;
|
||||||
|
return utf8.decode(decodedBytes);
|
||||||
|
} catch (e) {
|
||||||
|
Log.error('Failed to decode avatar SVG: $e');
|
||||||
|
return defaultAvatarSvg;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createPushAvatars({int? forceForUserId}) async {
|
Future<void> createPushAvatars({int? forceForUserId}) async {
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
117
lib/src/visual/components/delete_memories_dialog.comp.dart
Normal file
117
lib/src/visual/components/delete_memories_dialog.comp.dart
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||||
|
|
||||||
|
Future<bool?> showDeleteMemoriesDialog({
|
||||||
|
required BuildContext context,
|
||||||
|
required int count,
|
||||||
|
required bool hasCloudBackup,
|
||||||
|
}) {
|
||||||
|
return showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => DeleteMemoriesDialog(
|
||||||
|
count: count,
|
||||||
|
hasCloudBackup: hasCloudBackup,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class DeleteMemoriesDialog extends StatelessWidget {
|
||||||
|
const DeleteMemoriesDialog({
|
||||||
|
required this.count,
|
||||||
|
required this.hasCloudBackup,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final int count;
|
||||||
|
final bool hasCloudBackup;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 24,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.lang.deleteImageTitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
context.lang.deleteMemoriesBody(count),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (hasCloudBackup) ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.secondaryMiddle,
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(context.lang.deleteMemoriesLocalOnly),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.errorMiddle,
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(context.lang.deleteMemoriesCompletely),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
MyButton(
|
||||||
|
variant: MyButtonVariant.text,
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(context.lang.galleryCancel),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.secondaryMiddle,
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(context.lang.galleryCancel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.errorMiddle,
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(context.lang.delete),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -98,6 +98,7 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
|
||||||
height: 11,
|
height: 11,
|
||||||
child: EmojiAnimationComp(
|
child: EmojiAnimationComp(
|
||||||
emoji: '⌛',
|
emoji: '⌛',
|
||||||
|
repeat: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/services/key_verification.service.dart';
|
||||||
import 'package:twonly/src/utils/avatars.dart';
|
import 'package:twonly/src/utils/avatars.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/utils/qr.utils.dart';
|
import 'package:twonly/src/utils/qr.utils.dart';
|
||||||
|
|
@ -22,59 +24,73 @@ class ProfileQrCodeComp extends StatefulWidget {
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
Contact? contact,
|
Contact? contact,
|
||||||
bool openToVerify = false,
|
bool openToVerify = false,
|
||||||
}) {
|
}) async {
|
||||||
return showModalBottomSheet(
|
StreamSubscription<void>? subscription;
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
if (openToVerify) {
|
||||||
backgroundColor: Colors.transparent,
|
subscription = KeyVerificationService.onVerificationSuccessClose.listen((_) {
|
||||||
builder: (context) {
|
if (context.mounted) {
|
||||||
return Container(
|
KeyVerificationService.closeVerificationFlows(context);
|
||||||
decoration: BoxDecoration(
|
}
|
||||||
color: context.color.surface,
|
});
|
||||||
borderRadius: const BorderRadius.vertical(
|
}
|
||||||
top: Radius.circular(24),
|
|
||||||
|
try {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.color.surface,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(24),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
|
child: Column(
|
||||||
child: Column(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
const SizedBox(width: double.infinity),
|
||||||
const SizedBox(width: double.infinity),
|
Container(
|
||||||
Container(
|
width: 40,
|
||||||
width: 40,
|
height: 4,
|
||||||
height: 4,
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: context.color.onSurface.withValues(alpha: 0.2),
|
||||||
color: context.color.onSurface.withValues(alpha: 0.2),
|
borderRadius: BorderRadius.circular(2),
|
||||||
borderRadius: BorderRadius.circular(2),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
const SizedBox(height: 24),
|
Text(
|
||||||
Text(
|
openToVerify
|
||||||
openToVerify
|
? (contact != null
|
||||||
? (contact != null
|
? context.lang.letUserScanQrCode(
|
||||||
? context.lang.letUserScanQrCode(
|
getContactDisplayName(contact),
|
||||||
getContactDisplayName(contact),
|
)
|
||||||
)
|
: context.lang.letFriendScanQrToVerify)
|
||||||
: context.lang.letFriendScanQrToVerify)
|
: (contact != null
|
||||||
: (contact != null
|
? context.lang.letUserScanQrCode(
|
||||||
? context.lang.letUserScanQrCode(
|
getContactDisplayName(contact),
|
||||||
getContactDisplayName(contact),
|
)
|
||||||
)
|
: context.lang.addContactQrSheetSubtext),
|
||||||
: context.lang.addContactQrSheetSubtext),
|
style: TextStyle(
|
||||||
style: TextStyle(
|
fontSize: 14,
|
||||||
fontSize: 14,
|
color: context.color.onSurface.withValues(alpha: 0.6),
|
||||||
color: context.color.onSurface.withValues(alpha: 0.6),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
const SizedBox(height: 24),
|
||||||
),
|
const ProfileQrCodeComp(),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 34),
|
||||||
const ProfileQrCodeComp(),
|
],
|
||||||
const SizedBox(height: 34),
|
),
|
||||||
],
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
},
|
} finally {
|
||||||
);
|
await subscription?.cancel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/visual/themes/light.dart';
|
||||||
|
|
||||||
class SelectableThumbnailComp extends StatelessWidget {
|
class SelectableThumbnailComp extends StatelessWidget {
|
||||||
const SelectableThumbnailComp({
|
const SelectableThumbnailComp({
|
||||||
|
|
@ -19,7 +19,7 @@ class SelectableThumbnailComp extends StatelessWidget {
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected ? context.color.primary : Colors.transparent,
|
color: isSelected ? primaryColor : Colors.transparent,
|
||||||
boxShadow: const [
|
boxShadow: const [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black12,
|
color: Colors.black12,
|
||||||
|
|
@ -49,9 +49,7 @@ class SelectableThumbnailComp extends StatelessWidget {
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(2),
|
padding: const EdgeInsets.all(2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected ? primaryColor : Colors.black38,
|
||||||
? context.color.primary
|
|
||||||
: Colors.black38,
|
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).brightness == Brightness.dark
|
color: Theme.of(context).brightness == Brightness.dark
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ class VerificationBadgeComp extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
|
class _VerificationBadgeCompState extends State<VerificationBadgeComp> {
|
||||||
late bool _isVerified = widget.contact?.verified ?? true;
|
late bool _isVerified =
|
||||||
|
widget.contact?.verified ??
|
||||||
|
!(widget.isVerifiedByTransferredTrust ?? false);
|
||||||
bool _isSharedVerified = false;
|
bool _isSharedVerified = false;
|
||||||
int _verifiedByTransferredTrustCount = 0;
|
int _verifiedByTransferredTrustCount = 0;
|
||||||
int _sharedByVerifiedCount = 0;
|
int _sharedByVerifiedCount = 0;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/services/key_verification.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_success_animation.comp.dart';
|
import 'package:twonly/src/visual/components/verification_success_animation.comp.dart';
|
||||||
|
|
@ -18,11 +19,16 @@ class VerificationSuccessDialog extends StatelessWidget {
|
||||||
final String? message;
|
final String? message;
|
||||||
|
|
||||||
/// Utility method to easily present this dialog.
|
/// Utility method to easily present this dialog.
|
||||||
static Future<void> show(BuildContext context, Contact contact, {String? message}) {
|
static Future<void> show(
|
||||||
|
BuildContext context,
|
||||||
|
Contact contact, {
|
||||||
|
String? message,
|
||||||
|
}) {
|
||||||
return showDialog<void>(
|
return showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => VerificationSuccessDialog(contact: contact, message: message),
|
builder: (context) =>
|
||||||
|
VerificationSuccessDialog(contact: contact, message: message),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,7 +81,10 @@ class VerificationSuccessDialog extends StatelessWidget {
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
MyButton(
|
MyButton(
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () {
|
||||||
|
KeyVerificationService.notifyVerificationSuccessClose();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
child: Text(context.lang.close),
|
child: Text(context.lang.close),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/utils/avatars.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/verification_badge.comp.dart';
|
||||||
|
|
@ -10,49 +13,62 @@ import 'package:twonly/src/visual/views/contact/contact.view.dart';
|
||||||
|
|
||||||
class ContactChip extends StatelessWidget {
|
class ContactChip extends StatelessWidget {
|
||||||
const ContactChip({
|
const ContactChip({
|
||||||
required this.contact,
|
this.contact,
|
||||||
|
this.username,
|
||||||
|
this.avatarSvg,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
}) : assert(
|
||||||
|
contact != null || username != null,
|
||||||
|
'Either contact or username must be provided',
|
||||||
|
);
|
||||||
|
|
||||||
final Contact contact;
|
final Contact? contact;
|
||||||
|
final String? username;
|
||||||
|
final List<int>? avatarSvg;
|
||||||
final void Function(int)? onTap;
|
final void Function(int)? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ReactiveTapFeedback(
|
return ReactiveTapFeedback(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (contact == null) return;
|
||||||
if (onTap != null) {
|
if (onTap != null) {
|
||||||
onTap!(contact.userId);
|
onTap!(contact!.userId);
|
||||||
} else {
|
} else {
|
||||||
context.navPush(
|
context.navPush(
|
||||||
ContactView(
|
ContactView(
|
||||||
contact.userId,
|
contact!.userId,
|
||||||
key: ValueKey(contact.userId),
|
key: ValueKey(contact!.userId),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Chip(
|
child: Chip(
|
||||||
avatar: AvatarIcon(
|
avatar: AvatarIcon(
|
||||||
contactId: contact.userId,
|
contactId: contact?.userId,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
svg: avatarSvg != null
|
||||||
|
? getAvatarSvg(Uint8List.fromList(avatarSvg!))
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
label: Row(
|
label: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
getContactDisplayName(contact),
|
contact != null ? getContactDisplayName(contact!) : username!,
|
||||||
style: const TextStyle(fontSize: 14),
|
style: const TextStyle(fontSize: 14),
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
if (contact != null) ...[
|
||||||
VerificationBadgeComp(
|
const SizedBox(width: 6),
|
||||||
contact: contact,
|
VerificationBadgeComp(
|
||||||
size: 12,
|
contact: contact,
|
||||||
clickable: false,
|
size: 12,
|
||||||
),
|
clickable: false,
|
||||||
if (onTap != null) ...[
|
),
|
||||||
|
],
|
||||||
|
if (onTap != null && contact != null) ...[
|
||||||
const SizedBox(width: 15),
|
const SizedBox(width: 15),
|
||||||
const FaIcon(
|
const FaIcon(
|
||||||
FontAwesomeIcons.xmark,
|
FontAwesomeIcons.xmark,
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ enum MyButtonVariant {
|
||||||
secondaryDense,
|
secondaryDense,
|
||||||
secondaryMiddle,
|
secondaryMiddle,
|
||||||
error,
|
error,
|
||||||
|
errorMiddle,
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyButton extends StatefulWidget {
|
class MyButton extends StatefulWidget {
|
||||||
|
|
@ -33,7 +34,6 @@ class MyButton extends StatefulWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MyButtonState extends State<MyButton> {
|
class _MyButtonState extends State<MyButton> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isEnabled = widget.onPressed != null || widget.onLongPress != null;
|
final isEnabled = widget.onPressed != null || widget.onLongPress != null;
|
||||||
|
|
@ -189,6 +189,25 @@ class _MyButtonState extends State<MyButton> {
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
case MyButtonVariant.errorMiddle:
|
||||||
|
buttonStyle = FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
foregroundColor: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
disabledBackgroundColor: disabledBgColor,
|
||||||
|
disabledForegroundColor: disabledFgColor,
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 24,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final childButton = widget.variant == MyButtonVariant.text
|
final childButton = widget.variant == MyButtonVariant.text
|
||||||
|
|
|
||||||
|
|
@ -22,15 +22,14 @@ class _MediaViewSizingHelperState extends State<MediaViewSizingHelper> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var needToDownSizeImage = false;
|
var needToDownSizeImage = false;
|
||||||
var availableHeight = MediaQuery.of(context).size.height;
|
|
||||||
|
|
||||||
// Get the screen size and safe area padding
|
// Use narrow MediaQuery selectors to avoid rebuilding on keyboard inset changes
|
||||||
final screenSize = MediaQuery.of(context).size;
|
final screenSize = MediaQuery.sizeOf(context);
|
||||||
final safeAreaPadding = MediaQuery.of(context).padding;
|
final safeAreaPadding = MediaQuery.paddingOf(context);
|
||||||
|
|
||||||
// Calculate the available width and height
|
// Calculate the available width and height
|
||||||
final availableWidth = screenSize.width;
|
final availableWidth = screenSize.width;
|
||||||
availableHeight =
|
final availableHeight =
|
||||||
screenSize.height -
|
screenSize.height -
|
||||||
safeAreaPadding.top -
|
safeAreaPadding.top -
|
||||||
safeAreaPadding.bottom -
|
safeAreaPadding.bottom -
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/services/key_verification.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_view.dart';
|
||||||
|
|
@ -34,11 +35,7 @@ class QrCodeScannerViewState extends State<QrCodeScannerView> {
|
||||||
if (widget.openToVerify && widget.contact != null) {
|
if (widget.openToVerify && widget.contact != null) {
|
||||||
_mainCameraController.onVerificationSuccessDismissed = (contact) {
|
_mainCameraController.onVerificationSuccessDismissed = (contact) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.popUntil(context, (route) {
|
KeyVerificationService.closeVerificationFlows(context);
|
||||||
final name = route.settings.name;
|
|
||||||
if (name == null) return true;
|
|
||||||
return !name.contains('qr_scanner') && !name.contains('verifybadge');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,11 @@ import 'package:twonly/src/visual/components/connection_status.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/notification_badge.comp.dart';
|
import 'package:twonly/src/visual/components/notification_badge.comp.dart';
|
||||||
import 'package:twonly/src/visual/themes/light.dart';
|
import 'package:twonly/src/visual/themes/light.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart';
|
import 'package:twonly/src/visual/views/chats/chat_list_components/empty_chat_list.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/chat_list_components/feedback_btn.comp.dart';
|
|
||||||
import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart';
|
import 'package:twonly/src/visual/views/chats/chat_list_components/group_list_item.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/chat_list_components/news_btn.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart';
|
import 'package:twonly/src/visual/views/onboarding/setup/components/finish_setup.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart';
|
import 'package:twonly/src/visual/views/settings/backup/components/missing_backup_setup.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/components/missing_recovery_contacts.comp.dart';
|
||||||
|
|
||||||
class ChatListView extends StatefulWidget {
|
class ChatListView extends StatefulWidget {
|
||||||
const ChatListView({super.key});
|
const ChatListView({super.key});
|
||||||
|
|
@ -38,7 +39,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
List<Group> _groupsPinned = [];
|
List<Group> _groupsPinned = [];
|
||||||
List<Group> _groupsArchived = [];
|
List<Group> _groupsArchived = [];
|
||||||
|
|
||||||
bool _hasContacts = false;
|
final ValueNotifier<bool> _hasContacts = ValueNotifier(false);
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool get _hasOpenGroup =>
|
bool get _hasOpenGroup =>
|
||||||
_groupsNotPinned.isNotEmpty ||
|
_groupsNotPinned.isNotEmpty ||
|
||||||
|
|
@ -50,6 +51,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
|
|
||||||
int _countContactRequest = 0;
|
int _countContactRequest = 0;
|
||||||
int _countAnnouncedUsers = 0;
|
int _countAnnouncedUsers = 0;
|
||||||
|
final ValueNotifier<int> _badgeCount = ValueNotifier(0);
|
||||||
late StreamSubscription<int?> _countContactRequestStream;
|
late StreamSubscription<int?> _countContactRequestStream;
|
||||||
late StreamSubscription<int?> _countAnnouncedStream;
|
late StreamSubscription<int?> _countAnnouncedStream;
|
||||||
|
|
||||||
|
|
@ -80,9 +82,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
contacts,
|
contacts,
|
||||||
) {
|
) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
_hasContacts.value = contacts.isNotEmpty;
|
||||||
_hasContacts = contacts.isNotEmpty;
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_countContactRequestStream = twonlyDB.contactsDao
|
_countContactRequestStream = twonlyDB.contactsDao
|
||||||
|
|
@ -90,9 +90,8 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
.listen((update) {
|
.listen((update) {
|
||||||
if (update != null) {
|
if (update != null) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
_countContactRequest = update;
|
||||||
_countContactRequest = update;
|
_badgeCount.value = _countAnnouncedUsers + _countContactRequest;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -100,9 +99,8 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
.watchNewAnnouncementsWithDataCount()
|
.watchNewAnnouncementsWithDataCount()
|
||||||
.listen((update) {
|
.listen((update) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
_countAnnouncedUsers = update;
|
||||||
_countAnnouncedUsers = update;
|
_badgeCount.value = _countAnnouncedUsers + _countContactRequest;
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
_precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((mediaFiles) {
|
_precacheSub = twonlyDB.messagesDao.watchUnopenedMediaFiles().listen((mediaFiles) {
|
||||||
|
|
@ -141,7 +139,10 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
final plan = context.watch<PurchasesProvider>().plan;
|
final plan = context.select<PurchasesProvider, SubscriptionPlan>(
|
||||||
|
(p) => p.plan,
|
||||||
|
);
|
||||||
|
final dark = isDarkMode(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Row(
|
title: Row(
|
||||||
|
|
@ -175,7 +176,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: isDarkMode(context) ? Colors.black : Colors.white,
|
color: dark ? Colors.black : Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -183,41 +184,41 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
const FeedbackIconButtonComp(),
|
const NewsIconButtonComp(),
|
||||||
Stack(
|
ValueListenableBuilder<int>(
|
||||||
children: [
|
valueListenable: _badgeCount,
|
||||||
if (_countAnnouncedUsers + _countContactRequest > 0)
|
builder: (context, badgeCount, child) {
|
||||||
Positioned.fill(
|
return Stack(
|
||||||
child: Center(
|
children: [
|
||||||
child: Container(
|
if (badgeCount > 0)
|
||||||
width: 40,
|
Positioned.fill(
|
||||||
height: 40,
|
child: Center(
|
||||||
decoration: const BoxDecoration(
|
child: Container(
|
||||||
color: primaryColor,
|
width: 40,
|
||||||
shape: BoxShape.circle,
|
height: 40,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: primaryColor,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Center(
|
||||||
|
child: NotificationBadgeComp(
|
||||||
|
backgroundColor: dark ? Colors.white : Colors.black,
|
||||||
|
textColor: dark ? Colors.black : Colors.white,
|
||||||
|
count: badgeCount.toString(),
|
||||||
|
child: IconButton(
|
||||||
|
color: badgeCount > 0 ? Colors.black : null,
|
||||||
|
key: searchForOtherUsers,
|
||||||
|
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
|
||||||
|
onPressed: () => context.push(Routes.chatsAddNewUser),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
Center(
|
);
|
||||||
child: NotificationBadgeComp(
|
},
|
||||||
backgroundColor: isDarkMode(context)
|
|
||||||
? Colors.white
|
|
||||||
: Colors.black,
|
|
||||||
textColor: isDarkMode(context) ? Colors.black : Colors.white,
|
|
||||||
count: (_countAnnouncedUsers + _countContactRequest)
|
|
||||||
.toString(),
|
|
||||||
child: IconButton(
|
|
||||||
color: (_countAnnouncedUsers + _countContactRequest > 0)
|
|
||||||
? Colors.black
|
|
||||||
: null,
|
|
||||||
key: searchForOtherUsers,
|
|
||||||
icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18),
|
|
||||||
onPressed: () => context.push(Routes.chatsAddNewUser),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|
@ -236,6 +237,7 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
children: [
|
children: [
|
||||||
const FinishSetupComp(),
|
const FinishSetupComp(),
|
||||||
const MissingBackupComp(),
|
const MissingBackupComp(),
|
||||||
|
const MissingRecoveryContactsComp(),
|
||||||
if (_loading)
|
if (_loading)
|
||||||
const Expanded(
|
const Expanded(
|
||||||
child: SizedBox.shrink(),
|
child: SizedBox.shrink(),
|
||||||
|
|
@ -303,45 +305,43 @@ class _ChatListViewState extends State<ChatListView> with AutomaticKeepAliveClie
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
floatingActionButtonAnimator: FloatingActionButtonAnimator.noAnimation,
|
||||||
floatingActionButton: !_hasContacts
|
floatingActionButton: ValueListenableBuilder<bool>(
|
||||||
? null
|
valueListenable: _hasContacts,
|
||||||
: Padding(
|
builder: (context, hasContacts, child) {
|
||||||
padding: const EdgeInsets.only(bottom: 30),
|
if (!hasContacts) return const SizedBox.shrink();
|
||||||
child: Column(
|
return Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
padding: const EdgeInsets.only(bottom: 30),
|
||||||
children: [
|
child: Column(
|
||||||
FloatingActionButton(
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
heroTag: 'qrcode_fab',
|
children: [
|
||||||
elevation: 2,
|
FloatingActionButton(
|
||||||
backgroundColor: isDarkMode(context)
|
heroTag: 'qrcode_fab',
|
||||||
? Colors.grey[800]
|
elevation: 2,
|
||||||
: Colors.grey[200],
|
backgroundColor: dark ? Colors.grey[800] : Colors.grey[200],
|
||||||
foregroundColor: isDarkMode(context)
|
foregroundColor: dark ? Colors.white : Colors.black87,
|
||||||
? Colors.white
|
onPressed: () => context.push(Routes.settingsPublicProfile),
|
||||||
: Colors.black87,
|
child: FaIcon(
|
||||||
onPressed: () => context.push(Routes.settingsPublicProfile),
|
FontAwesomeIcons.qrcode,
|
||||||
child: FaIcon(
|
color: dark ? Colors.white : Colors.black87,
|
||||||
FontAwesomeIcons.qrcode,
|
|
||||||
color: isDarkMode(context)
|
|
||||||
? Colors.white
|
|
||||||
: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
FloatingActionButton(
|
const SizedBox(height: 12),
|
||||||
heroTag: 'new_chat_fab',
|
FloatingActionButton(
|
||||||
elevation: 2,
|
heroTag: 'new_chat_fab',
|
||||||
backgroundColor: primaryColor,
|
elevation: 2,
|
||||||
foregroundColor: Colors.black87,
|
backgroundColor: primaryColor,
|
||||||
onPressed: () => context.push(Routes.chatsStartNewChat),
|
foregroundColor: Colors.black87,
|
||||||
child: const FaIcon(
|
onPressed: () => context.push(Routes.chatsStartNewChat),
|
||||||
FontAwesomeIcons.penToSquare,
|
child: const FaIcon(
|
||||||
color: Colors.black87,
|
FontAwesomeIcons.penToSquare,
|
||||||
),
|
color: Colors.black87,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:twonly/locator.dart';
|
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
|
||||||
|
|
||||||
class FeedbackIconButtonComp extends StatelessWidget {
|
|
||||||
const FeedbackIconButtonComp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return StreamBuilder(
|
|
||||||
stream: userService.onUserUpdated,
|
|
||||||
builder: (context, asyncSnapshot) {
|
|
||||||
if (!userService.currentUser.showFeedbackShortcut) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
return IconButton(
|
|
||||||
onPressed: () => context.push(Routes.settingsHelpContactUs),
|
|
||||||
color: Colors.grey,
|
|
||||||
tooltip: context.lang.feedbackTooltip,
|
|
||||||
icon: const FaIcon(FontAwesomeIcons.commentDots, size: 19),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -174,19 +174,23 @@ class _UserListItem extends State<GroupListItemComp> {
|
||||||
/// Fetches any media files referenced by preview messages but not yet in the
|
/// Fetches any media files referenced by preview messages but not yet in the
|
||||||
/// local cache. Fire-and-forget; updates state when results arrive.
|
/// local cache. Fire-and-forget; updates state when results arrive.
|
||||||
Future<void> _fetchMissingMediaFiles() async {
|
Future<void> _fetchMissingMediaFiles() async {
|
||||||
|
final missing = <MediaFile>[];
|
||||||
for (final message in _previewMessages) {
|
for (final message in _previewMessages) {
|
||||||
if (message.mediaId != null &&
|
if (message.mediaId != null &&
|
||||||
!_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) {
|
!_previewMediaFiles.any((t) => t.mediaId == message.mediaId)) {
|
||||||
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
|
final mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
|
||||||
message.mediaId!,
|
message.mediaId!,
|
||||||
);
|
);
|
||||||
if (mediaFile != null && mounted) {
|
if (mediaFile != null) {
|
||||||
setState(() {
|
missing.add(mediaFile);
|
||||||
_previewMediaFiles.add(mediaFile);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (missing.isNotEmpty && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_previewMediaFiles.addAll(missing);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onTap() async {
|
Future<void> onTap() async {
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,6 @@ class _LastMessageTimeCompState extends State<LastMessageTimeComp> {
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loadTargetTime();
|
_loadTargetTime();
|
||||||
|
|
||||||
updateTime = Timer.periodic(
|
|
||||||
const Duration(milliseconds: 500),
|
|
||||||
(_) => _updateSeconds(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -66,6 +61,16 @@ class _LastMessageTimeCompState extends State<LastMessageTimeComp> {
|
||||||
setState(() {
|
setState(() {
|
||||||
lastMessageInSeconds = seconds < 0 ? 0 : seconds;
|
lastMessageInSeconds = seconds < 0 ? 0 : seconds;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var nextTickMs = 1000;
|
||||||
|
if (lastMessageInSeconds >= 120 && lastMessageInSeconds < 3600) {
|
||||||
|
nextTickMs = 30000;
|
||||||
|
} else if (lastMessageInSeconds >= 3600) {
|
||||||
|
nextTickMs = 60000;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTime?.cancel();
|
||||||
|
updateTime = Timer(Duration(milliseconds: nextTickMs), _updateSeconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/components/notification_badge.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/themes/light.dart';
|
||||||
|
|
||||||
|
class NewsIconButtonComp extends StatelessWidget {
|
||||||
|
const NewsIconButtonComp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StreamBuilder(
|
||||||
|
stream: userService.onUserUpdated,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!userService.currentUser.showNewsShortcut) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return ValueListenableBuilder<int>(
|
||||||
|
valueListenable: newsService.unreadCountNotifier,
|
||||||
|
builder: (context, count, child) {
|
||||||
|
return NotificationBadgeComp(
|
||||||
|
count: count.toString(),
|
||||||
|
backgroundColor: primaryColor,
|
||||||
|
textColor: Colors.black87,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: () => context.push(Routes.settingsHelpNews),
|
||||||
|
color: Colors.grey,
|
||||||
|
tooltip: context.lang.settingsHelpNews,
|
||||||
|
icon: const FaIcon(FontAwesomeIcons.bullhorn, size: 19),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,10 +28,6 @@ class _TypingIndicatorSubtitleCompState
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_periodicUpdate = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
||||||
filterOpenUsers(_groupMembers);
|
|
||||||
});
|
|
||||||
|
|
||||||
final membersStream = twonlyDB.groupsDao.watchGroupMembers(
|
final membersStream = twonlyDB.groupsDao.watchGroupMembers(
|
||||||
widget.groupId,
|
widget.groupId,
|
||||||
);
|
);
|
||||||
|
|
@ -41,11 +37,22 @@ class _TypingIndicatorSubtitleCompState
|
||||||
}
|
}
|
||||||
|
|
||||||
void filterOpenUsers(List<GroupMember> input) {
|
void filterOpenUsers(List<GroupMember> input) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
|
||||||
_groupMembers = input.where(isTyping).toList();
|
final typingMembers = input.where(isTyping).toList();
|
||||||
|
|
||||||
|
if (typingMembers.isEmpty) {
|
||||||
|
_periodicUpdate?.cancel();
|
||||||
|
_periodicUpdate = null;
|
||||||
|
} else {
|
||||||
|
_periodicUpdate ??= Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
filterOpenUsers(_groupMembers);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_groupMembers = typingMembers;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import 'dart:collection';
|
||||||
import 'package:clock/clock.dart';
|
import 'package:clock/clock.dart';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:mutex/mutex.dart';
|
import 'package:mutex/mutex.dart';
|
||||||
import 'package:screen_protector/screen_protector.dart';
|
import 'package:screen_protector/screen_protector.dart';
|
||||||
|
|
@ -24,13 +23,13 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/services/notifications/background.notifications.dart';
|
import 'package:twonly/src/services/notifications/background.notifications.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
|
|
||||||
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
|
|
||||||
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
||||||
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
|
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
|
||||||
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
|
import 'package:twonly/src/visual/views/camera/camera_send_to.view.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart';
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/additional_message_content.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/keyboard_dismiss_observer.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart';
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_content_renderer.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_bottom_navigation.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart';
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/media_viewer_message_input.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/reaction_buttons.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart';
|
import 'package:twonly/src/visual/views/chats/media_viewer_components/twonly_present_overlay.comp.dart';
|
||||||
|
|
@ -63,7 +62,6 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
DateTime? canBeSeenUntil;
|
DateTime? canBeSeenUntil;
|
||||||
final ValueNotifier<double> progress = ValueNotifier(0);
|
final ValueNotifier<double> progress = ValueNotifier(0);
|
||||||
bool showSendTextMessageInput = false;
|
bool showSendTextMessageInput = false;
|
||||||
double maxBottomInset = 0;
|
|
||||||
DateTime? _lastTimeInputClosed;
|
DateTime? _lastTimeInputClosed;
|
||||||
final GlobalKey mediaWidgetKey = GlobalKey();
|
final GlobalKey mediaWidgetKey = GlobalKey();
|
||||||
|
|
||||||
|
|
@ -366,7 +364,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
return advanceToNextMediaOrExit();
|
return advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The server can now delete the encrypted bytes, as the users has sucessfully opened it.
|
// The server can now delete the encrypted bytes, as the user has successfully opened it.
|
||||||
Log.info(
|
Log.info(
|
||||||
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
|
'Calling downloadDone for media ID: ${currentMediaLocal.mediaFile.mediaId}',
|
||||||
);
|
);
|
||||||
|
|
@ -505,7 +503,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
advanceToNextMediaOrExit();
|
advanceToNextMediaOrExit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
progressTimer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
|
progressTimer = Timer.periodic(const Duration(milliseconds: 16), (timer) {
|
||||||
final mediaFile = currentMedia?.mediaFile;
|
final mediaFile = currentMedia?.mediaFile;
|
||||||
if (mediaFile == null) return;
|
if (mediaFile == null) return;
|
||||||
if (mediaFile.displayLimitInMilliseconds == null ||
|
if (mediaFile.displayLimitInMilliseconds == null ||
|
||||||
|
|
@ -562,107 +560,49 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget bottomNavigation() {
|
Widget bottomNavigation() {
|
||||||
return Row(
|
return MediaViewerBottomNavigationBar(
|
||||||
key: mediaWidgetKey,
|
key: mediaWidgetKey,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
currentMedia: currentMedia,
|
||||||
children: [
|
currentMessage: currentMessage,
|
||||||
if (currentMedia != null &&
|
imageSaving: imageSaving,
|
||||||
currentMessage != null &&
|
imageSaved: imageSaved,
|
||||||
!currentMedia!.mediaFile.requiresAuthentication &&
|
showShortReactions: showShortReactions,
|
||||||
currentMedia!.mediaFile.displayLimitInMilliseconds == null)
|
onSaveToGallery: onPressedSaveToGallery,
|
||||||
MyIconButton(
|
onToggleReactions: () {
|
||||||
variant: MyIconButtonVariant.secondary,
|
if (!showShortReactions) {
|
||||||
onPressed: (currentMedia == null || currentMessage == null)
|
displayShortReactions();
|
||||||
? null
|
} else {
|
||||||
: onPressedSaveToGallery,
|
setState(() {
|
||||||
icon: imageSaving
|
showShortReactions = false;
|
||||||
? const SizedBox(
|
});
|
||||||
width: 16,
|
}
|
||||||
height: 16,
|
},
|
||||||
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
onMessagePressed: () {
|
||||||
)
|
displayShortReactions();
|
||||||
: imageSaved
|
setState(() {
|
||||||
? const Icon(Icons.check)
|
showSendTextMessageInput = true;
|
||||||
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 20),
|
});
|
||||||
|
},
|
||||||
|
onCameraPressed: () async {
|
||||||
|
nextMediaTimer?.cancel();
|
||||||
|
progressTimer?.cancel();
|
||||||
|
await videoController?.pause();
|
||||||
|
if (!mounted) return;
|
||||||
|
await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) {
|
||||||
|
return CameraSendToView(widget.group);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
);
|
||||||
IconButton(
|
if (mounted &&
|
||||||
icon: SizedBox(
|
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
||||||
width: 30,
|
await advanceToNextMediaOrExit();
|
||||||
height: 30,
|
} else {
|
||||||
child: GridView.count(
|
await videoController?.play();
|
||||||
crossAxisCount: 2,
|
}
|
||||||
children: List.generate(
|
},
|
||||||
4,
|
|
||||||
(index) {
|
|
||||||
return SizedBox(
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
child: Center(
|
|
||||||
child: EmojiAnimationComp(
|
|
||||||
emoji: EmojiAnimationComp.animatedIcons.keys
|
|
||||||
.toList()[index],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
if (!showShortReactions) {
|
|
||||||
displayShortReactions();
|
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
showShortReactions = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
style: ButtonStyle(
|
|
||||||
padding: WidgetStateProperty.all<EdgeInsets>(
|
|
||||||
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
MyIconButton(
|
|
||||||
variant: MyIconButtonVariant.secondary,
|
|
||||||
onPressed: () async {
|
|
||||||
displayShortReactions();
|
|
||||||
setState(() {
|
|
||||||
showSendTextMessageInput = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: const FaIcon(
|
|
||||||
FontAwesomeIcons.message,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
MyIconButton(
|
|
||||||
onPressed: () async {
|
|
||||||
nextMediaTimer?.cancel();
|
|
||||||
progressTimer?.cancel();
|
|
||||||
await videoController?.pause();
|
|
||||||
if (!mounted) return;
|
|
||||||
await Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (context) {
|
|
||||||
return CameraSendToView(widget.group);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (mounted &&
|
|
||||||
currentMedia!.mediaFile.displayLimitInMilliseconds != null) {
|
|
||||||
await advanceToNextMediaOrExit();
|
|
||||||
} else {
|
|
||||||
await videoController?.play();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const FaIcon(FontAwesomeIcons.camera, size: 24),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -717,124 +657,118 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bottomInset = MediaQuery.of(context).viewInsets.bottom;
|
return KeyboardDismissObserver(
|
||||||
if (bottomInset > maxBottomInset) {
|
showSendTextMessageInput: showSendTextMessageInput,
|
||||||
maxBottomInset = bottomInset;
|
onKeyboardDismissed: () {
|
||||||
} else if (bottomInset == 0 && maxBottomInset > 0) {
|
setState(() {
|
||||||
maxBottomInset = 0;
|
showSendTextMessageInput = false;
|
||||||
if (showSendTextMessageInput) {
|
showShortReactions = false;
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
_lastTimeInputClosed = clock.now();
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
showSendTextMessageInput = false;
|
|
||||||
showShortReactions = false;
|
|
||||||
_lastTimeInputClosed = clock.now();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
}
|
child: Scaffold(
|
||||||
|
body: SafeArea(
|
||||||
return Scaffold(
|
child: Stack(
|
||||||
body: SafeArea(
|
fit: StackFit.expand,
|
||||||
child: Stack(
|
children: [
|
||||||
fit: StackFit.expand,
|
if (_showDownloadingLoader) _loader(),
|
||||||
children: [
|
if ((currentMedia != null || videoController != null) &&
|
||||||
if (_showDownloadingLoader) _loader(),
|
(canBeSeenUntil == null || progress.value >= 0))
|
||||||
if ((currentMedia != null || videoController != null) &&
|
GestureDetector(
|
||||||
(canBeSeenUntil == null || progress.value >= 0))
|
onTap: onScreenTapped,
|
||||||
GestureDetector(
|
onDoubleTap: (videoController == null)
|
||||||
onTap: onScreenTapped,
|
? null
|
||||||
onDoubleTap: (videoController == null) ? null : onScreenTapped,
|
: onScreenTapped,
|
||||||
child: MediaViewSizingHelper(
|
child: MediaViewSizingHelper(
|
||||||
bottomNavigation: bottomNavigation(),
|
bottomNavigation: bottomNavigation(),
|
||||||
requiredHeight: 55,
|
requiredHeight: 55,
|
||||||
child: MediaContentRenderer(
|
child: MediaContentRenderer(
|
||||||
currentMedia: currentMedia,
|
currentMedia: currentMedia,
|
||||||
videoController: videoController,
|
videoController: videoController,
|
||||||
loader: _loader(),
|
loader: _loader(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (displayTwonlyPresent)
|
||||||
|
TwonlyPresentOverlay(
|
||||||
|
onTap: () => loadAndDownloadCurrentMedia(showTwonly: true),
|
||||||
|
),
|
||||||
|
if (currentMedia != null &&
|
||||||
|
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
||||||
|
Positioned.fill(child: _loader()),
|
||||||
|
if (canBeSeenUntil != null || progress.value >= 0)
|
||||||
|
Positioned(
|
||||||
|
right: 20,
|
||||||
|
top: 27,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: ValueListenableBuilder<double>(
|
||||||
|
valueListenable: progress,
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return CircularProgressIndicator(
|
||||||
|
value: value,
|
||||||
|
strokeWidth: 2,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 10,
|
||||||
|
left: showSendTextMessageInput ? 0 : null,
|
||||||
|
right: showSendTextMessageInput ? 0 : 15,
|
||||||
|
child: Text(
|
||||||
|
_currentMediaSender,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: showSendTextMessageInput ? 24 : 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: showSendTextMessageInput
|
||||||
|
? null
|
||||||
|
: const Color.fromARGB(255, 126, 126, 126),
|
||||||
|
shadows: const [
|
||||||
|
Shadow(
|
||||||
|
color: Color.fromARGB(122, 0, 0, 0),
|
||||||
|
blurRadius: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (displayTwonlyPresent)
|
if (showSendTextMessageInput)
|
||||||
TwonlyPresentOverlay(
|
MediaViewerMessageInput(
|
||||||
onTap: () => loadAndDownloadCurrentMedia(showTwonly: true),
|
controller: textMessageController,
|
||||||
),
|
onSubmitted: (value) => _sendTextMessage(),
|
||||||
if (currentMedia != null &&
|
onSendPressed: _sendTextMessage,
|
||||||
currentMedia?.mediaFile.downloadState != DownloadState.ready)
|
|
||||||
Positioned.fill(child: _loader()),
|
|
||||||
if (canBeSeenUntil != null || progress.value >= 0)
|
|
||||||
Positioned(
|
|
||||||
right: 20,
|
|
||||||
top: 27,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: ValueListenableBuilder<double>(
|
|
||||||
valueListenable: progress,
|
|
||||||
builder: (context, value, child) {
|
|
||||||
return CircularProgressIndicator(
|
|
||||||
value: value,
|
|
||||||
strokeWidth: 2,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
if (currentMessage != null)
|
||||||
Positioned(
|
AdditionalMessageContent(currentMessage!),
|
||||||
top: 10,
|
if (currentMedia != null)
|
||||||
left: showSendTextMessageInput ? 0 : null,
|
ReactionButtons(
|
||||||
right: showSendTextMessageInput ? 0 : 15,
|
show: showShortReactions,
|
||||||
child: Text(
|
textInputFocused: showSendTextMessageInput,
|
||||||
_currentMediaSender,
|
mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
|
||||||
textAlign: TextAlign.center,
|
groupId: widget.group.groupId,
|
||||||
style: TextStyle(
|
messageId: currentMessage!.messageId,
|
||||||
fontSize: showSendTextMessageInput ? 24 : 14,
|
emojiKey: emojiKey,
|
||||||
fontWeight: FontWeight.bold,
|
hide: () {
|
||||||
color: showSendTextMessageInput
|
setState(() {
|
||||||
? null
|
showShortReactions = false;
|
||||||
: const Color.fromARGB(255, 126, 126, 126),
|
showSendTextMessageInput = false;
|
||||||
shadows: const [
|
_lastTimeInputClosed = clock.now();
|
||||||
Shadow(
|
});
|
||||||
color: Color.fromARGB(122, 0, 0, 0),
|
},
|
||||||
blurRadius: 5,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: EmojiFloatWidget(key: emojiKey),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
if (showSendTextMessageInput)
|
),
|
||||||
MediaViewerMessageInput(
|
|
||||||
controller: textMessageController,
|
|
||||||
onSubmitted: (value) => _sendTextMessage(),
|
|
||||||
onSendPressed: _sendTextMessage,
|
|
||||||
),
|
|
||||||
if (currentMessage != null)
|
|
||||||
AdditionalMessageContent(currentMessage!),
|
|
||||||
if (currentMedia != null)
|
|
||||||
ReactionButtons(
|
|
||||||
show: showShortReactions,
|
|
||||||
textInputFocused: showSendTextMessageInput,
|
|
||||||
mediaViewerDistanceFromBottom: mediaViewerDistanceFromBottom,
|
|
||||||
groupId: widget.group.groupId,
|
|
||||||
messageId: currentMessage!.messageId,
|
|
||||||
emojiKey: emojiKey,
|
|
||||||
hide: () {
|
|
||||||
setState(() {
|
|
||||||
showShortReactions = false;
|
|
||||||
showSendTextMessageInput = false;
|
|
||||||
_lastTimeInputClosed = clock.now();
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
|
||||||
child: EmojiFloatWidget(key: emojiKey),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -80,11 +80,18 @@ class _EmojiReactionWidgetState extends State<EmojiReactionWidget> {
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 40,
|
width: 40,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: widget.show
|
child: AnimatedOpacity(
|
||||||
? EmojiAnimationComp(
|
opacity: widget.show ? 1.0 : 0.0,
|
||||||
emoji: widget.emoji,
|
duration: widget.show
|
||||||
)
|
? const Duration(milliseconds: 150)
|
||||||
: const SizedBox.shrink(),
|
: Duration.zero,
|
||||||
|
child: Offstage(
|
||||||
|
offstage: !widget.show,
|
||||||
|
child: EmojiAnimationComp(
|
||||||
|
emoji: widget.emoji,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Observes keyboard dismiss via viewInsets without causing the parent to
|
||||||
|
/// rebuild on every keyboard animation frame.
|
||||||
|
class KeyboardDismissObserver extends StatefulWidget {
|
||||||
|
const KeyboardDismissObserver({
|
||||||
|
required this.showSendTextMessageInput,
|
||||||
|
required this.onKeyboardDismissed,
|
||||||
|
required this.child,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool showSendTextMessageInput;
|
||||||
|
final VoidCallback onKeyboardDismissed;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<KeyboardDismissObserver> createState() =>
|
||||||
|
_KeyboardDismissObserverState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KeyboardDismissObserverState extends State<KeyboardDismissObserver> {
|
||||||
|
double _maxBottomInset = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
|
||||||
|
if (bottomInset > _maxBottomInset) {
|
||||||
|
_maxBottomInset = bottomInset;
|
||||||
|
} else if (bottomInset == 0 && _maxBottomInset > 0) {
|
||||||
|
_maxBottomInset = 0;
|
||||||
|
if (widget.showSendTextMessageInput) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
widget.onKeyboardDismissed();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return widget.child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:twonly/src/visual/components/animate_icon.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_icon_button.element.dart';
|
||||||
|
|
||||||
|
/// Extracted bottom navigation bar widget to provide subtree isolation.
|
||||||
|
/// Flutter can skip rebuilding this widget when only the parent's unrelated
|
||||||
|
/// state changes (e.g. progress timer, keyboard insets).
|
||||||
|
class MediaViewerBottomNavigationBar extends StatelessWidget {
|
||||||
|
const MediaViewerBottomNavigationBar({
|
||||||
|
required this.currentMedia,
|
||||||
|
required this.currentMessage,
|
||||||
|
required this.imageSaving,
|
||||||
|
required this.imageSaved,
|
||||||
|
required this.showShortReactions,
|
||||||
|
required this.onSaveToGallery,
|
||||||
|
required this.onToggleReactions,
|
||||||
|
required this.onMessagePressed,
|
||||||
|
required this.onCameraPressed,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MediaFileService? currentMedia;
|
||||||
|
final Message? currentMessage;
|
||||||
|
final bool imageSaving;
|
||||||
|
final bool imageSaved;
|
||||||
|
final bool showShortReactions;
|
||||||
|
final VoidCallback onSaveToGallery;
|
||||||
|
final VoidCallback onToggleReactions;
|
||||||
|
final VoidCallback onMessagePressed;
|
||||||
|
final VoidCallback onCameraPressed;
|
||||||
|
|
||||||
|
/// Pre-built static icon for the emoji grid button so the 4 Lottie
|
||||||
|
/// animations are created only once.
|
||||||
|
static final Widget _emojiGridIcon = SizedBox(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
child: GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
children: List.generate(
|
||||||
|
4,
|
||||||
|
(index) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
child: Center(
|
||||||
|
child: EmojiAnimationComp(
|
||||||
|
emoji: EmojiAnimationComp.animatedIconKeys[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
if (currentMedia != null &&
|
||||||
|
currentMessage != null &&
|
||||||
|
!currentMedia!.mediaFile.requiresAuthentication &&
|
||||||
|
currentMedia!.mediaFile.displayLimitInMilliseconds == null)
|
||||||
|
MyIconButton(
|
||||||
|
variant: MyIconButtonVariant.secondary,
|
||||||
|
onPressed: (currentMedia == null || currentMessage == null)
|
||||||
|
? null
|
||||||
|
: onSaveToGallery,
|
||||||
|
icon: imageSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: imageSaved
|
||||||
|
? const Icon(Icons.check)
|
||||||
|
: const FaIcon(FontAwesomeIcons.floppyDisk, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
IconButton(
|
||||||
|
icon: _emojiGridIcon,
|
||||||
|
onPressed: onToggleReactions,
|
||||||
|
style: ButtonStyle(
|
||||||
|
padding: WidgetStateProperty.all<EdgeInsets>(
|
||||||
|
const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
MyIconButton(
|
||||||
|
variant: MyIconButtonVariant.secondary,
|
||||||
|
onPressed: onMessagePressed,
|
||||||
|
icon: const FaIcon(
|
||||||
|
FontAwesomeIcons.message,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
MyIconButton(
|
||||||
|
onPressed: onCameraPressed,
|
||||||
|
icon: const FaIcon(FontAwesomeIcons.camera, size: 24),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -335,9 +335,10 @@ class _ParticlePainter extends CustomPainter {
|
||||||
_ParticlePainter(this.particles);
|
_ParticlePainter(this.particles);
|
||||||
final List<_Particle> particles;
|
final List<_Particle> particles;
|
||||||
|
|
||||||
|
static final _textPainter = TextPainter(textDirection: TextDirection.ltr);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void paint(Canvas canvas, Size size) {
|
void paint(Canvas canvas, Size size) {
|
||||||
final textPainter = TextPainter(textDirection: TextDirection.ltr);
|
|
||||||
for (final p in particles) {
|
for (final p in particles) {
|
||||||
final tp = TextSpan(
|
final tp = TextSpan(
|
||||||
text: p.emoji,
|
text: p.emoji,
|
||||||
|
|
@ -350,14 +351,17 @@ class _ParticlePainter extends CustomPainter {
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
textPainter
|
_textPainter
|
||||||
..text = tp
|
..text = tp
|
||||||
..layout();
|
..layout();
|
||||||
canvas
|
canvas
|
||||||
..save()
|
..save()
|
||||||
..translate(p.x - textPainter.width / 2, p.y - textPainter.height / 2)
|
..translate(
|
||||||
|
p.x - _textPainter.width / 2,
|
||||||
|
p.y - _textPainter.height / 2,
|
||||||
|
)
|
||||||
..rotate(p.rotation);
|
..rotate(p.rotation);
|
||||||
textPainter.paint(canvas, Offset.zero);
|
_textPainter.paint(canvas, Offset.zero);
|
||||||
canvas.restore();
|
canvas.restore();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,15 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:app_links/app_links.dart';
|
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||||
import 'package:flutter_sharing_intent/model/sharing_file.dart';
|
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:twonly/globals.dart';
|
import 'package:twonly/globals.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/constants/routes.keys.dart';
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/providers/routing.provider.dart';
|
import 'package:twonly/src/providers/routing.provider.dart';
|
||||||
import 'package:twonly/src/services/intent/links.intent.dart';
|
|
||||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
import 'package:twonly/src/services/notifications/setup.notifications.dart';
|
import 'package:twonly/src/services/notifications/setup.notifications.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
|
@ -46,13 +43,13 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
final MainCameraController _mainCameraController = MainCameraController();
|
final MainCameraController _mainCameraController = MainCameraController();
|
||||||
late final PageController _homeViewPageController;
|
late final PageController _homeViewPageController;
|
||||||
|
|
||||||
StreamSubscription<List<SharedFile>>? _intentStreamSub;
|
StreamSubscription<Uri>? _sharedLinkSub;
|
||||||
StreamSubscription<Uri>? _deepLinkSub;
|
|
||||||
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
|
||||||
StreamSubscription<int>? _homeViewPageIndexSub;
|
StreamSubscription<int>? _homeViewPageIndexSub;
|
||||||
StreamSubscription<NotificationResponse>? _selectNotificationSub;
|
StreamSubscription<NotificationResponse>? _selectNotificationSub;
|
||||||
|
|
||||||
static final streamHomeViewPageIndex = StreamController<int>.broadcast();
|
static final streamHomeViewPageIndex = StreamController<int>.broadcast();
|
||||||
|
static final streamSharedLink = StreamController<Uri>.broadcast();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -113,26 +110,8 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
|
|
||||||
unawaited(_initAsync());
|
unawaited(_initAsync());
|
||||||
|
|
||||||
void handleShareLink(Uri uri) {
|
_sharedLinkSub = streamSharedLink.stream.listen(
|
||||||
routerProvider.go(Routes.home);
|
_mainCameraController.setSharedLinkForPreview,
|
||||||
streamHomeViewPageIndex.add(1);
|
|
||||||
_mainCameraController.setSharedLinkForPreview(uri);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to all events (initial link and further)
|
|
||||||
_deepLinkSub = AppLinks().uriLinkStream.listen((uri) async {
|
|
||||||
if (!mounted) return;
|
|
||||||
Log.info('Got link via app links: ${uri.scheme}');
|
|
||||||
if (!await handleIntentUrl(context, uri)) {
|
|
||||||
if (uri.scheme.startsWith('http')) {
|
|
||||||
handleShareLink(uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_intentStreamSub = initIntentStreams(
|
|
||||||
context,
|
|
||||||
handleShareLink,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
|
@ -207,8 +186,7 @@ class HomeViewState extends State<HomeView> with WidgetsBindingObserver {
|
||||||
_disableCameraTimer?.cancel();
|
_disableCameraTimer?.cancel();
|
||||||
_mainCameraController.setState = null;
|
_mainCameraController.setState = null;
|
||||||
_mainCameraController.closeCamera();
|
_mainCameraController.closeCamera();
|
||||||
_intentStreamSub?.cancel();
|
_sharedLinkSub?.cancel();
|
||||||
_deepLinkSub?.cancel();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
|
||||||
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||||
|
|
||||||
|
class MemoriesCloudBackupPromoComp extends StatelessWidget {
|
||||||
|
const MemoriesCloudBackupPromoComp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StreamBuilder<void>(
|
||||||
|
stream: userService.onUserUpdated,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final user = userService.currentUser;
|
||||||
|
if (user.isCloudBackupEnabled || user.hideMemoriesBackupPromo) {
|
||||||
|
return const SliverToBoxAdapter(
|
||||||
|
child: SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
context.color.primaryContainer.withValues(alpha: 0.15),
|
||||||
|
context.color.primaryContainer.withValues(alpha: 0.05),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.color.primary.withValues(alpha: 0.1),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.lang.memoriesBackupTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
color: context.color.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
context.lang.settingsStorageNoCloudBackupCard,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: context.color.onSurfaceVariant,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
MyButton(
|
||||||
|
variant: MyButtonVariant.primaryDense,
|
||||||
|
onPressed: () async {
|
||||||
|
await UserService.update(
|
||||||
|
(u) => u.isCloudBackupEnabled = true,
|
||||||
|
);
|
||||||
|
unawaited(memoriesCloudService.checkUploads());
|
||||||
|
},
|
||||||
|
child: Text(context.lang.enable),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
MyButton(
|
||||||
|
variant: MyButtonVariant.secondaryDense,
|
||||||
|
onPressed: () async {
|
||||||
|
await UserService.update(
|
||||||
|
(u) => u.hideMemoriesBackupPromo = true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
context.lang.settingsStorageHidePromo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/model/memory_item.model.dart';
|
import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
|
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
|
||||||
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
import 'package:twonly/src/visual/components/selectable_thumbnail.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
|
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
|
||||||
|
|
||||||
|
|
@ -31,8 +37,10 @@ class MemoriesThumbnailComp extends StatefulWidget {
|
||||||
|
|
||||||
class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
ImageProvider? _imageProvider;
|
ImageProvider? _imageProvider;
|
||||||
|
File? _selectedImageFile;
|
||||||
ImageStream? _imageStream;
|
ImageStream? _imageStream;
|
||||||
ImageInfo? _imageInfo;
|
ImageInfo? _imageInfo;
|
||||||
|
int _retries = 0;
|
||||||
late final ImageStreamListener _listener;
|
late final ImageStreamListener _listener;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -52,6 +60,7 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
setState(() {
|
setState(() {
|
||||||
_imageProvider = null;
|
_imageProvider = null;
|
||||||
_imageInfo = null;
|
_imageInfo = null;
|
||||||
|
_selectedImageFile = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -60,6 +69,7 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resolveImage() {
|
void _resolveImage() {
|
||||||
|
if (_retries > 3) return;
|
||||||
final media = widget.galleryItem.mediaService;
|
final media = widget.galleryItem.mediaService;
|
||||||
final hasThumbnail =
|
final hasThumbnail =
|
||||||
media.thumbnailPath.existsSync() &&
|
media.thumbnailPath.existsSync() &&
|
||||||
|
|
@ -70,17 +80,43 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
media.mediaFile.type == MediaType.image ||
|
media.mediaFile.type == MediaType.image ||
|
||||||
media.mediaFile.type == MediaType.gif;
|
media.mediaFile.type == MediaType.gif;
|
||||||
|
|
||||||
|
_selectedImageFile = null;
|
||||||
|
|
||||||
if (hasThumbnail) {
|
if (hasThumbnail) {
|
||||||
_imageProvider = FileImage(media.thumbnailPath);
|
_imageProvider = FileImage(media.thumbnailPath);
|
||||||
|
_selectedImageFile = media.thumbnailPath;
|
||||||
} else if (hasStored && isImageOrGif) {
|
} else if (hasStored && isImageOrGif) {
|
||||||
_imageProvider = FileImage(media.storedPath);
|
_imageProvider = FileImage(media.storedPath);
|
||||||
|
_selectedImageFile = media.storedPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasThumbnail) {
|
||||||
|
if (hasStored) {
|
||||||
|
unawaited(
|
||||||
|
media.createThumbnail().then((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_resolveImage();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
unawaited(
|
||||||
|
MemoriesCloudService.downloadFromCloud(media, isThumbnail: true).then(
|
||||||
|
(success) {
|
||||||
|
if (mounted && success) {
|
||||||
|
_resolveImage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_imageProvider != null) {
|
if (_imageProvider != null) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final config = createLocalImageConfiguration(context);
|
final config = createLocalImageConfiguration(context);
|
||||||
_imageStream = _imageProvider!.resolve(config);
|
_imageStream = _imageProvider?.resolve(config);
|
||||||
_imageStream!.addListener(_listener);
|
_imageStream!.addListener(_listener);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -92,7 +128,11 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
if (oldWidget.galleryItem.mediaService.mediaFile.mediaId !=
|
if (oldWidget.galleryItem.mediaService.mediaFile.mediaId !=
|
||||||
widget.galleryItem.mediaService.mediaFile.mediaId) {
|
widget.galleryItem.mediaService.mediaFile.mediaId) {
|
||||||
_imageStream?.removeListener(_listener);
|
_imageStream?.removeListener(_listener);
|
||||||
|
_imageStream = null;
|
||||||
|
_imageProvider = null;
|
||||||
_imageInfo = null;
|
_imageInfo = null;
|
||||||
|
_retries = 0;
|
||||||
|
_selectedImageFile = null;
|
||||||
_resolveImage();
|
_resolveImage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -148,6 +188,14 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
errorBuilder: (context, error, stackTrace) {
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
if (error.toString().contains('Invalid image data')) {
|
||||||
|
if (_selectedImageFile != null) {
|
||||||
|
_selectedImageFile?.deleteSync();
|
||||||
|
_retries++;
|
||||||
|
_resolveImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.warn(error);
|
||||||
return ColoredBox(
|
return ColoredBox(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
child: const Center(
|
child: const Center(
|
||||||
|
|
@ -159,6 +207,11 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
else if (media.mediaFile.blurhash != null)
|
||||||
|
BlurHash(
|
||||||
|
hash: media.mediaFile.blurhash!,
|
||||||
|
optimizationMode: BlurHashOptimizationMode.approximation,
|
||||||
|
)
|
||||||
else
|
else
|
||||||
ColoredBox(
|
ColoredBox(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
|
|
@ -195,6 +248,45 @@ class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final hasStored =
|
||||||
|
media.storedPath.existsSync() &&
|
||||||
|
media.storedPath.lengthSync() > 0;
|
||||||
|
final IconData iconData;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
switch (media.mediaFile.cloudState) {
|
||||||
|
case CloudState.none:
|
||||||
|
iconData = Icons.cloud_off_outlined;
|
||||||
|
color = Colors.white54;
|
||||||
|
case CloudState.pending:
|
||||||
|
iconData = Icons.cloud_upload_outlined;
|
||||||
|
color = Colors.white70;
|
||||||
|
case CloudState.uploaded:
|
||||||
|
if (hasStored) {
|
||||||
|
iconData = Icons.cloud_done_outlined;
|
||||||
|
color = Colors.white;
|
||||||
|
} else {
|
||||||
|
iconData = Icons.cloud_outlined;
|
||||||
|
color = Colors.white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Positioned(
|
||||||
|
bottom: 6,
|
||||||
|
right: 6,
|
||||||
|
child: Icon(
|
||||||
|
iconData,
|
||||||
|
color: color,
|
||||||
|
size: 16,
|
||||||
|
shadows: const [
|
||||||
|
Shadow(color: Colors.black54, blurRadius: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
|
class MemoriesSelectionMenuComp extends StatelessWidget {
|
||||||
|
const MemoriesSelectionMenuComp({
|
||||||
|
required this.areAllSelected,
|
||||||
|
required this.onSelectAll,
|
||||||
|
required this.onExport,
|
||||||
|
required this.onFavorite,
|
||||||
|
required this.onDeleteCompletely,
|
||||||
|
required this.onDeleteLocally,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool areAllSelected;
|
||||||
|
final VoidCallback onSelectAll;
|
||||||
|
final VoidCallback onExport;
|
||||||
|
final VoidCallback onFavorite;
|
||||||
|
final VoidCallback onDeleteCompletely;
|
||||||
|
final VoidCallback onDeleteLocally;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
onSelected: (val) {
|
||||||
|
switch (val) {
|
||||||
|
case 'selectAll':
|
||||||
|
onSelectAll();
|
||||||
|
case 'export':
|
||||||
|
onExport();
|
||||||
|
case 'favorite':
|
||||||
|
onFavorite();
|
||||||
|
case 'delete':
|
||||||
|
onDeleteCompletely();
|
||||||
|
case 'deleteLocal':
|
||||||
|
onDeleteLocally();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'selectAll',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
areAllSelected ? Icons.deselect : Icons.select_all,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
areAllSelected
|
||||||
|
? context.lang.memoriesMenuDeselectAll
|
||||||
|
: context.lang.memoriesMenuSelectAll,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'export',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.file_download_outlined,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(context.lang.memoriesMenuExport),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'favorite',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.favorite_border,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(context.lang.memoriesMenuFavorite),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.delete_forever_outlined,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.redAccent,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
context.lang.memoriesMenuDelete,
|
||||||
|
style: const TextStyle(color: Colors.redAccent),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (userService.currentUser.isCloudBackupEnabled)
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'deleteLocal',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.phonelink_erase_outlined,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(context.lang.memoriesMenuDeleteLocal),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
|
||||||
|
|
||||||
class MemoriesSelectionToolbarComp extends StatelessWidget {
|
|
||||||
const MemoriesSelectionToolbarComp({
|
|
||||||
required this.selectedCount,
|
|
||||||
required this.areAllSelected,
|
|
||||||
required this.areAllFav,
|
|
||||||
required this.onSelectAll,
|
|
||||||
required this.onExport,
|
|
||||||
required this.onFavorite,
|
|
||||||
required this.onDelete,
|
|
||||||
required this.onClear,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int selectedCount;
|
|
||||||
final bool areAllSelected;
|
|
||||||
final bool areAllFav;
|
|
||||||
final VoidCallback onSelectAll;
|
|
||||||
final VoidCallback onExport;
|
|
||||||
final VoidCallback onFavorite;
|
|
||||||
final VoidCallback onDelete;
|
|
||||||
final VoidCallback onClear;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Positioned(
|
|
||||||
bottom: MediaQuery.paddingOf(context).bottom + 24,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: context.color.surface.withValues(alpha: 0.95),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
boxShadow: const [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black26,
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
border: Border.all(
|
|
||||||
color: context.color.primary.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'$selectedCount',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
areAllSelected ? Icons.deselect : Icons.select_all,
|
|
||||||
size: 22,
|
|
||||||
),
|
|
||||||
onPressed: onSelectAll,
|
|
||||||
tooltip: areAllSelected
|
|
||||||
? context.lang.galleryDeselectAll
|
|
||||||
: context.lang.gallerySelectAll,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: context.color.primary.withValues(
|
|
||||||
alpha: 0.1,
|
|
||||||
),
|
|
||||||
foregroundColor: context.color.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.file_download_outlined, size: 22),
|
|
||||||
onPressed: onExport,
|
|
||||||
tooltip: context.lang.galleryExport,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: context.color.primary.withValues(
|
|
||||||
alpha: 0.1,
|
|
||||||
),
|
|
||||||
foregroundColor: context.color.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
areAllFav ? Icons.favorite : Icons.favorite_border,
|
|
||||||
size: 22,
|
|
||||||
color: areAllFav ? Colors.redAccent : null,
|
|
||||||
),
|
|
||||||
onPressed: onFavorite,
|
|
||||||
tooltip: areAllFav
|
|
||||||
? context.lang.galleryUnfavorite
|
|
||||||
: context.lang.galleryFavorite,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: context.color.primary.withValues(
|
|
||||||
alpha: 0.1,
|
|
||||||
),
|
|
||||||
foregroundColor: context.color.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.delete_outline, size: 22),
|
|
||||||
onPressed: onDelete,
|
|
||||||
tooltip: context.lang.galleryDelete,
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: Colors.redAccent.withValues(
|
|
||||||
alpha: 0.1,
|
|
||||||
),
|
|
||||||
foregroundColor: Colors.redAccent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, size: 20),
|
|
||||||
onPressed: onClear,
|
|
||||||
tooltip: context.lang.galleryCancel,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/themes/light.dart';
|
|
||||||
|
|
||||||
class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
|
class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
|
||||||
const SynchronizedViewerActionsToolbarComp({
|
const SynchronizedViewerActionsToolbarComp({
|
||||||
|
|
@ -27,94 +26,125 @@ class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Positioned(
|
return Padding(
|
||||||
bottom: MediaQuery.paddingOf(context).bottom + 24,
|
padding: const EdgeInsets.all(12),
|
||||||
left: 0,
|
child: Container(
|
||||||
right: 0,
|
decoration: BoxDecoration(
|
||||||
child: Row(
|
color: Colors.black.withValues(alpha: 0.50),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
borderRadius: BorderRadius.circular(20),
|
||||||
children: [
|
),
|
||||||
if (showStoreButton) ...[
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
IconButton(
|
child: Row(
|
||||||
icon: isImageSaving
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
? const SizedBox(
|
children: [
|
||||||
width: 16,
|
if (showStoreButton)
|
||||||
height: 16,
|
_ToolbarAction(
|
||||||
child: CircularProgressIndicator.adaptive(
|
icon: isImageSaving
|
||||||
strokeWidth: 2,
|
? const SizedBox(
|
||||||
valueColor: AlwaysStoppedAnimation(Colors.white),
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator.adaptive(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const FaIcon(
|
||||||
|
FontAwesomeIcons.floppyDisk,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
),
|
),
|
||||||
)
|
label: context.lang.galleryActionSave,
|
||||||
: const FaIcon(
|
onTap: isImageSaving ? null : onStore,
|
||||||
FontAwesomeIcons.floppyDisk,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
onPressed: isImageSaving ? null : onStore,
|
|
||||||
tooltip: 'Store media',
|
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
),
|
||||||
|
_ToolbarAction(
|
||||||
|
icon: const FaIcon(
|
||||||
|
FontAwesomeIcons.download,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 19,
|
||||||
|
),
|
||||||
|
label: context.lang.galleryActionExport,
|
||||||
|
onTap: onExport,
|
||||||
|
),
|
||||||
|
_ToolbarAction(
|
||||||
|
icon: Icon(
|
||||||
|
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||||
|
color: isFavorite ? Colors.redAccent : Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
label: isFavorite
|
||||||
|
? context.lang.galleryActionUnfavorite
|
||||||
|
: context.lang.galleryActionFavorite,
|
||||||
|
onTap: onToggleFavorite,
|
||||||
|
),
|
||||||
|
_ToolbarAction(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
label: context.lang.galleryActionDelete,
|
||||||
|
onTap: onDelete,
|
||||||
|
),
|
||||||
|
_ToolbarAction(
|
||||||
|
icon: const FaIcon(
|
||||||
|
FontAwesomeIcons.shareNodes,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 19,
|
||||||
|
),
|
||||||
|
label: context.lang.galleryActionShare,
|
||||||
|
onTap: onShare,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
|
||||||
],
|
],
|
||||||
IconButton(
|
),
|
||||||
icon: const FaIcon(
|
),
|
||||||
FontAwesomeIcons.fileArrowDown,
|
);
|
||||||
color: Colors.white,
|
}
|
||||||
size: 21,
|
}
|
||||||
),
|
|
||||||
onPressed: onExport,
|
class _ToolbarAction extends StatelessWidget {
|
||||||
tooltip: context.lang.galleryExport,
|
const _ToolbarAction({
|
||||||
style: IconButton.styleFrom(
|
required this.icon,
|
||||||
backgroundColor: Colors.black54,
|
required this.label,
|
||||||
padding: const EdgeInsets.all(12),
|
required this.onTap,
|
||||||
),
|
});
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
final Widget icon;
|
||||||
IconButton(
|
final String label;
|
||||||
icon: Icon(
|
final VoidCallback? onTap;
|
||||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
|
||||||
color: isFavorite ? Colors.redAccent : Colors.white,
|
@override
|
||||||
size: 24,
|
Widget build(BuildContext context) {
|
||||||
),
|
return Expanded(
|
||||||
onPressed: onToggleFavorite,
|
child: Opacity(
|
||||||
tooltip: 'Favorite',
|
opacity: onTap == null ? 0.4 : 1.0,
|
||||||
style: IconButton.styleFrom(
|
child: InkWell(
|
||||||
backgroundColor: Colors.black54,
|
onTap: onTap,
|
||||||
padding: const EdgeInsets.all(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
child: Padding(
|
||||||
),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
const SizedBox(width: 16),
|
child: Column(
|
||||||
IconButton(
|
mainAxisSize: MainAxisSize.min,
|
||||||
icon: const Icon(
|
children: [
|
||||||
Icons.delete_outline,
|
SizedBox(
|
||||||
color: Colors.white,
|
height: 24,
|
||||||
size: 24,
|
child: Center(child: icon),
|
||||||
),
|
),
|
||||||
onPressed: onDelete,
|
const SizedBox(height: 6),
|
||||||
tooltip: context.lang.galleryDelete,
|
Text(
|
||||||
style: IconButton.styleFrom(
|
label,
|
||||||
backgroundColor: Colors.black54,
|
maxLines: 1,
|
||||||
padding: const EdgeInsets.all(12),
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
style: TextStyle(
|
||||||
),
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
const SizedBox(width: 16),
|
fontSize: 10,
|
||||||
IconButton(
|
fontWeight: FontWeight.w600,
|
||||||
icon: const FaIcon(
|
letterSpacing: 0.1,
|
||||||
FontAwesomeIcons.solidPaperPlane,
|
),
|
||||||
color: primaryColor,
|
),
|
||||||
size: 22,
|
],
|
||||||
),
|
),
|
||||||
onPressed: onShare,
|
),
|
||||||
tooltip: context.lang.shareImagedEditorSendImage,
|
),
|
||||||
style: IconButton.styleFrom(
|
|
||||||
backgroundColor: Colors.black54,
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||||
|
import 'package:photo_view/photo_view.dart';
|
||||||
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
|
import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
|
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
|
||||||
|
import 'package:twonly/src/visual/helpers/video_player_file.helper.dart';
|
||||||
|
|
||||||
|
class SynchronizedViewerItemComp extends StatefulWidget {
|
||||||
|
const SynchronizedViewerItemComp({
|
||||||
|
required this.item,
|
||||||
|
required this.currentlyViewedMediaIdNotifier,
|
||||||
|
required this.onZoomChanged,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final MemoryItem item;
|
||||||
|
final ValueNotifier<String> currentlyViewedMediaIdNotifier;
|
||||||
|
final ValueChanged<bool> onZoomChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SynchronizedViewerItemComp> createState() =>
|
||||||
|
_SynchronizedViewerItemCompState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SynchronizedViewerItemCompState
|
||||||
|
extends State<SynchronizedViewerItemComp> {
|
||||||
|
bool _isDownloading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkAndDownload();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant SynchronizedViewerItemComp oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.item.mediaService.mediaFile.mediaId !=
|
||||||
|
widget.item.mediaService.mediaFile.mediaId) {
|
||||||
|
_checkAndDownload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkAndDownload() {
|
||||||
|
final media = widget.item.mediaService;
|
||||||
|
final hasStored =
|
||||||
|
media.storedPath.existsSync() && media.storedPath.lengthSync() > 0;
|
||||||
|
|
||||||
|
if (!hasStored &&
|
||||||
|
media.mediaFile.cloudState == CloudState.uploaded &&
|
||||||
|
!_isDownloading) {
|
||||||
|
_isDownloading = true;
|
||||||
|
unawaited(
|
||||||
|
MemoriesCloudService.downloadFromCloud(
|
||||||
|
media,
|
||||||
|
isThumbnail: false,
|
||||||
|
).then((success) async {
|
||||||
|
if (success && mounted) {
|
||||||
|
final fullPath = media.storedPath;
|
||||||
|
if (media.mediaFile.type == MediaType.image ||
|
||||||
|
media.mediaFile.type == MediaType.gif) {
|
||||||
|
if (fullPath.existsSync()) {
|
||||||
|
try {
|
||||||
|
await precacheImage(FileImage(fullPath), context);
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore precache errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isDownloading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final item = widget.item;
|
||||||
|
final itemMediaId = item.mediaService.mediaFile.mediaId;
|
||||||
|
|
||||||
|
var filePath = item.mediaService.storedPath;
|
||||||
|
final hasStored = filePath.existsSync() && filePath.lengthSync() > 0;
|
||||||
|
if (!hasStored) {
|
||||||
|
filePath = item.mediaService.tempPath;
|
||||||
|
final hasTemp = filePath.existsSync() && filePath.lengthSync() > 0;
|
||||||
|
if (!hasTemp) {
|
||||||
|
filePath = item.mediaService.thumbnailPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final isVideo = item.mediaService.mediaFile.type == MediaType.video;
|
||||||
|
final hasVideoFile =
|
||||||
|
isVideo &&
|
||||||
|
(item.mediaService.storedPath.existsSync() &&
|
||||||
|
item.mediaService.storedPath.lengthSync() > 0 ||
|
||||||
|
item.mediaService.tempPath.existsSync() &&
|
||||||
|
item.mediaService.tempPath.lengthSync() > 0);
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: ValueListenableBuilder<String>(
|
||||||
|
valueListenable: widget.currentlyViewedMediaIdNotifier,
|
||||||
|
builder: (context, activeMediaId, childWidget) {
|
||||||
|
final isActiveTarget = activeMediaId == itemMediaId;
|
||||||
|
|
||||||
|
if (isActiveTarget) {
|
||||||
|
return Hero(
|
||||||
|
tag: itemMediaId,
|
||||||
|
transitionOnUserGestures: true,
|
||||||
|
child: childWidget!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return childWidget!;
|
||||||
|
},
|
||||||
|
child: !filePath.existsSync() || filePath.lengthSync() == 0
|
||||||
|
? item.mediaService.mediaFile.blurhash != null
|
||||||
|
? BlurHash(
|
||||||
|
hash: item.mediaService.mediaFile.blurhash!,
|
||||||
|
optimizationMode: BlurHashOptimizationMode.approximation,
|
||||||
|
)
|
||||||
|
: const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
color: Colors.white38,
|
||||||
|
size: 64,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: hasVideoFile
|
||||||
|
? VideoPlayerFileHelper(videoPath: filePath)
|
||||||
|
: PhotoView(
|
||||||
|
key: ValueKey(filePath.path),
|
||||||
|
imageProvider: FileImage(filePath),
|
||||||
|
initialScale: PhotoViewComputedScale.contained,
|
||||||
|
minScale: PhotoViewComputedScale.contained,
|
||||||
|
maxScale: PhotoViewComputedScale.covered * 4.1,
|
||||||
|
backgroundDecoration: const BoxDecoration(
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
color: Colors.white38,
|
||||||
|
size: 64,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
scaleStateChangedCallback: (state) {
|
||||||
|
final zoomed = state != PhotoViewScaleState.initial;
|
||||||
|
widget.onZoomChanged(zoomed);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
|
@ -7,11 +8,13 @@ import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
import 'package:twonly/src/services/memories/memories.service.dart';
|
import 'package:twonly/src/services/memories/memories.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||||
|
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart';
|
import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
|
import 'package:twonly/src/visual/views/memories/components/cloud_backup_promo.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart';
|
import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
|
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/components/selection_toolbar.comp.dart';
|
import 'package:twonly/src/visual/views/memories/components/selection_menu.comp.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
|
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
|
||||||
|
|
||||||
class MemoriesView extends StatefulWidget {
|
class MemoriesView extends StatefulWidget {
|
||||||
|
|
@ -21,7 +24,8 @@ class MemoriesView extends StatefulWidget {
|
||||||
State<MemoriesView> createState() => MemoriesViewState();
|
State<MemoriesView> createState() => MemoriesViewState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class MemoriesViewState extends State<MemoriesView> with AutomaticKeepAliveClientMixin<MemoriesView> {
|
class MemoriesViewState extends State<MemoriesView>
|
||||||
|
with AutomaticKeepAliveClientMixin<MemoriesView> {
|
||||||
late final MemoriesService _service;
|
late final MemoriesService _service;
|
||||||
final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(null);
|
final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(null);
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
@ -31,11 +35,27 @@ class MemoriesViewState extends State<MemoriesView> with AutomaticKeepAliveClien
|
||||||
bool _filterFavoritesOnly = false;
|
bool _filterFavoritesOnly = false;
|
||||||
bool get _selectionMode => _selectedMediaIds.isNotEmpty;
|
bool get _selectionMode => _selectedMediaIds.isNotEmpty;
|
||||||
|
|
||||||
|
bool _isUsageLimitReached = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_service = MemoriesService();
|
_service = MemoriesService();
|
||||||
_activeMediaIdNotifier.addListener(_onActiveMediaChanged);
|
_activeMediaIdNotifier.addListener(_onActiveMediaChanged);
|
||||||
|
_checkUsage();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkUsage() async {
|
||||||
|
final usage = await apiService.getMemoriesUsage();
|
||||||
|
if (usage != null &&
|
||||||
|
usage.maxBytes > 0 &&
|
||||||
|
usage.currentBytes >= usage.maxBytes) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isUsageLimitReached = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -253,19 +273,42 @@ class MemoriesViewState extends State<MemoriesView> with AutomaticKeepAliveClien
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _batchDelete() async {
|
Future<void> _batchDelete({bool? forceDeleteCompletely}) async {
|
||||||
final count = _selectedMediaIds.length;
|
final count = _selectedMediaIds.length;
|
||||||
final confirmed = await showAlertDialog(
|
|
||||||
context,
|
|
||||||
context.lang.deleteImageTitle,
|
|
||||||
context.lang.deleteMemoriesBody(count),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
final items = _service.currentState.galleryItems;
|
final items = _service.currentState.galleryItems;
|
||||||
final selectedList = _selectedMediaIds.toList();
|
final selectedList = _selectedMediaIds.toList();
|
||||||
|
|
||||||
|
var deleteCompletely = forceDeleteCompletely;
|
||||||
|
if (deleteCompletely == null) {
|
||||||
|
var hasCloudBackup = false;
|
||||||
|
for (final id in selectedList) {
|
||||||
|
final item = items
|
||||||
|
.where((e) => e.mediaService.mediaFile.mediaId == id)
|
||||||
|
.firstOrNull;
|
||||||
|
if (item != null &&
|
||||||
|
item.mediaService.mediaFile.cloudState != CloudState.none) {
|
||||||
|
hasCloudBackup = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCompletely = await showDeleteMemoriesDialog(
|
||||||
|
context: context,
|
||||||
|
count: count,
|
||||||
|
hasCloudBackup: hasCloudBackup,
|
||||||
|
);
|
||||||
|
} else if (deleteCompletely) {
|
||||||
|
final confirmed = await showAlertDialog(
|
||||||
|
context,
|
||||||
|
context.lang.deleteImageTitle,
|
||||||
|
context.lang.deleteMemoriesBody(count),
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteCompletely == null) return;
|
||||||
|
final isCompletely = deleteCompletely;
|
||||||
|
|
||||||
await _showProgressDialog(
|
await _showProgressDialog(
|
||||||
'Deleting memories...',
|
'Deleting memories...',
|
||||||
(setProgress) async {
|
(setProgress) async {
|
||||||
|
|
@ -275,9 +318,16 @@ class MemoriesViewState extends State<MemoriesView> with AutomaticKeepAliveClien
|
||||||
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
if (item != null) {
|
if (item != null) {
|
||||||
item.mediaService.fullMediaRemoval();
|
if (isCompletely) {
|
||||||
|
item.mediaService.fullMediaRemoval();
|
||||||
|
await apiService.deleteMemory(mediaId);
|
||||||
|
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
||||||
|
} else {
|
||||||
|
if (item.mediaService.storedPath.existsSync()) {
|
||||||
|
item.mediaService.storedPath.deleteSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
|
||||||
setProgress((i + 1) / selectedList.length);
|
setProgress((i + 1) / selectedList.length);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -406,10 +456,8 @@ class MemoriesViewState extends State<MemoriesView> with AutomaticKeepAliveClien
|
||||||
Text(
|
Text(
|
||||||
context.lang.memoriesEmpty,
|
context.lang.memoriesEmpty,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
color: Colors.grey,
|
||||||
color: Colors.grey.shade600,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -446,200 +494,268 @@ class MemoriesViewState extends State<MemoriesView> with AutomaticKeepAliveClien
|
||||||
orderedByMonth = filteredOrdered;
|
orderedByMonth = filteredOrdered;
|
||||||
}
|
}
|
||||||
|
|
||||||
return LayoutBuilder(
|
return Column(
|
||||||
builder: (context, constraints) {
|
children: [
|
||||||
return DraggableScrollbar(
|
if (_isUsageLimitReached)
|
||||||
controller: _scrollController,
|
Container(
|
||||||
labelBuilder: (offset) {
|
color: Colors.redAccent,
|
||||||
final state = _service.currentState;
|
width: double.infinity,
|
||||||
if (state.isEmpty || state.months.isEmpty) return null;
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8,
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
context.lang.memoriesBackupLimitReached,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
return DraggableScrollbar(
|
||||||
|
controller: _scrollController,
|
||||||
|
labelBuilder: (offset) {
|
||||||
|
final state = _service.currentState;
|
||||||
|
if (state.isEmpty || state.months.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// Simple heuristic to find month by offset
|
// Simple heuristic to find month by offset
|
||||||
double currentOffset = 56;
|
double currentOffset = 56;
|
||||||
if (state.galleryItemsLastYears.isNotEmpty) {
|
if (state.galleryItemsLastYears.isNotEmpty) {
|
||||||
currentOffset += 220;
|
currentOffset += 220;
|
||||||
}
|
}
|
||||||
|
|
||||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
final screenWidth = MediaQuery.sizeOf(
|
||||||
final itemWidth = (screenWidth - 8) / 4;
|
context,
|
||||||
final itemHeight = itemWidth * (16 / 9);
|
).width;
|
||||||
final rowHeight = itemHeight + 2;
|
final itemWidth = (screenWidth - 8) / 4;
|
||||||
|
final itemHeight = itemWidth * (16 / 9);
|
||||||
|
final rowHeight = itemHeight + 2;
|
||||||
|
|
||||||
for (final month in state.months) {
|
for (final month in state.months) {
|
||||||
final indices = state.orderedByMonth[month]!;
|
final indices = state.orderedByMonth[month]!;
|
||||||
final totalRows = (indices.length + 3) ~/ 4;
|
final totalRows = (indices.length + 3) ~/ 4;
|
||||||
final monthHeight = 44 + (totalRows * rowHeight);
|
final monthHeight = 44 + (totalRows * rowHeight);
|
||||||
|
|
||||||
if (offset < currentOffset + monthHeight) {
|
if (offset < currentOffset + monthHeight) {
|
||||||
return month;
|
return month;
|
||||||
}
|
}
|
||||||
currentOffset += monthHeight;
|
currentOffset += monthHeight;
|
||||||
}
|
}
|
||||||
return state.months.last;
|
return state.months.last;
|
||||||
},
|
},
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
title: const Text(
|
leading: _selectionMode
|
||||||
'Memories',
|
? IconButton(
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
icon: const Icon(Icons.arrow_back),
|
||||||
),
|
onPressed: () =>
|
||||||
floating: true,
|
setState(_selectedMediaIds.clear),
|
||||||
snap: true,
|
)
|
||||||
elevation: 0,
|
: null,
|
||||||
backgroundColor: context.color.surface,
|
title: _selectionMode
|
||||||
actions: [
|
? Builder(
|
||||||
if (state.isLoading)
|
builder: (context) => Text(
|
||||||
Padding(
|
context.lang.memoriesSelectedCount(
|
||||||
padding: const EdgeInsets.symmetric(
|
_selectedMediaIds.length,
|
||||||
horizontal: 16,
|
),
|
||||||
),
|
style: const TextStyle(
|
||||||
child: Center(
|
fontWeight: FontWeight.bold,
|
||||||
child: Tooltip(
|
),
|
||||||
message: context.lang.migrationOfMemories(
|
|
||||||
state.filesToMigrate,
|
|
||||||
),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
value: state.migrationProgress,
|
|
||||||
strokeWidth: 2.5,
|
|
||||||
valueColor: AlwaysStoppedAnimation(
|
|
||||||
context.color.primary,
|
|
||||||
),
|
),
|
||||||
backgroundColor: context.color.primary
|
)
|
||||||
.withValues(alpha: 0.2),
|
: const Text(
|
||||||
|
'Memories',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
pinned: _selectionMode,
|
||||||
|
floating: true,
|
||||||
|
snap: true,
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: context.color.surface,
|
||||||
|
actions: _selectionMode
|
||||||
|
? [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
),
|
||||||
|
onPressed: _batchDelete,
|
||||||
|
),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final visibleCount =
|
||||||
|
_filterFavoritesOnly
|
||||||
|
? state.galleryItems
|
||||||
|
.where(
|
||||||
|
(e) => e
|
||||||
|
.mediaService
|
||||||
|
.mediaFile
|
||||||
|
.isFavorite,
|
||||||
|
)
|
||||||
|
.length
|
||||||
|
: state.galleryItems.length;
|
||||||
|
final areAllSelected =
|
||||||
|
visibleCount > 0 &&
|
||||||
|
_selectedMediaIds.length >=
|
||||||
|
visibleCount;
|
||||||
|
|
||||||
|
return MemoriesSelectionMenuComp(
|
||||||
|
areAllSelected: areAllSelected,
|
||||||
|
onSelectAll: _selectAll,
|
||||||
|
onExport: _batchExport,
|
||||||
|
onFavorite: _batchFavorite,
|
||||||
|
onDeleteCompletely: () =>
|
||||||
|
_batchDelete(
|
||||||
|
forceDeleteCompletely: true,
|
||||||
|
),
|
||||||
|
onDeleteLocally: () =>
|
||||||
|
_batchDelete(
|
||||||
|
forceDeleteCompletely:
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
if (state.isLoading)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Tooltip(
|
||||||
|
message: context.lang
|
||||||
|
.migrationOfMemories(
|
||||||
|
state.filesToMigrate,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value:
|
||||||
|
state.migrationProgress,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
valueColor:
|
||||||
|
AlwaysStoppedAnimation(
|
||||||
|
context.color.primary,
|
||||||
|
),
|
||||||
|
backgroundColor: context
|
||||||
|
.color
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_filterFavoritesOnly
|
||||||
|
? Icons.favorite
|
||||||
|
: Icons.favorite_border,
|
||||||
|
color: _filterFavoritesOnly
|
||||||
|
? Colors.redAccent
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
_filterFavoritesOnly =
|
||||||
|
!_filterFavoritesOnly;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MemoriesFlashbackBannerComp(
|
||||||
|
lastYears: lastYears,
|
||||||
|
onOpenFlashback: (items, idx) =>
|
||||||
|
_openViewer(items, idx, isFlashback: true),
|
||||||
|
),
|
||||||
|
const MemoriesCloudBackupPromoComp(),
|
||||||
|
for (final month in months) ...[
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
8,
|
||||||
|
12,
|
||||||
|
8,
|
||||||
|
6,
|
||||||
|
),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: Text(
|
||||||
|
month,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
SliverGrid(
|
||||||
IconButton(
|
gridDelegate:
|
||||||
icon: Icon(
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
_filterFavoritesOnly
|
crossAxisCount: 4,
|
||||||
? Icons.favorite
|
mainAxisSpacing: 2,
|
||||||
: Icons.favorite_border,
|
crossAxisSpacing: 2,
|
||||||
color: _filterFavoritesOnly
|
childAspectRatio: 9 / 16,
|
||||||
? Colors.redAccent
|
),
|
||||||
: null,
|
delegate: SliverChildBuilderDelegate(
|
||||||
),
|
(context, idx) {
|
||||||
onPressed: () {
|
final globalIndex =
|
||||||
setState(() {
|
orderedByMonth[month]![idx];
|
||||||
_filterFavoritesOnly = !_filterFavoritesOnly;
|
final item =
|
||||||
});
|
state.galleryItems[globalIndex];
|
||||||
},
|
final mediaId =
|
||||||
tooltip: _filterFavoritesOnly
|
item.mediaService.mediaFile.mediaId;
|
||||||
? 'Show all'
|
final isSelected = _selectedMediaIds
|
||||||
: 'Show favorites only',
|
.contains(
|
||||||
),
|
mediaId,
|
||||||
],
|
);
|
||||||
),
|
|
||||||
MemoriesFlashbackBannerComp(
|
|
||||||
lastYears: lastYears,
|
|
||||||
onOpenFlashback: (items, idx) =>
|
|
||||||
_openViewer(items, idx, isFlashback: true),
|
|
||||||
),
|
|
||||||
for (final month in months) ...[
|
|
||||||
SliverPadding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 6),
|
|
||||||
sliver: SliverToBoxAdapter(
|
|
||||||
child: Text(
|
|
||||||
month,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SliverGrid(
|
|
||||||
gridDelegate:
|
|
||||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 4,
|
|
||||||
mainAxisSpacing: 2,
|
|
||||||
crossAxisSpacing: 2,
|
|
||||||
childAspectRatio: 9 / 16,
|
|
||||||
),
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
|
||||||
(context, idx) {
|
|
||||||
final globalIndex = orderedByMonth[month]![idx];
|
|
||||||
final item = state.galleryItems[globalIndex];
|
|
||||||
final mediaId =
|
|
||||||
item.mediaService.mediaFile.mediaId;
|
|
||||||
final isSelected = _selectedMediaIds.contains(
|
|
||||||
mediaId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return MemoriesThumbnailComp(
|
return MemoriesThumbnailComp(
|
||||||
galleryItem: item,
|
galleryItem: item,
|
||||||
index: globalIndex,
|
index: globalIndex,
|
||||||
selectionMode: _selectionMode,
|
selectionMode: _selectionMode,
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
activeMediaIdNotifier: _activeMediaIdNotifier,
|
activeMediaIdNotifier:
|
||||||
onLongPress: () => _onLongPressItem(mediaId),
|
_activeMediaIdNotifier,
|
||||||
onTap: () => _onTapItem(mediaId, globalIndex),
|
onLongPress: () =>
|
||||||
);
|
_onLongPressItem(mediaId),
|
||||||
},
|
onTap: () =>
|
||||||
childCount: orderedByMonth[month]!.length,
|
_onTapItem(mediaId, globalIndex),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
childCount: orderedByMonth[month]!.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
SliverPadding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
bottom:
|
||||||
|
MediaQuery.of(context).padding.bottom +
|
||||||
|
150,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
);
|
||||||
SliverPadding(
|
},
|
||||||
padding: EdgeInsets.only(
|
|
||||||
bottom: MediaQuery.of(context).padding.bottom + 150,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (_selectionMode)
|
|
||||||
Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final items = _service.currentState.galleryItems;
|
|
||||||
var visibleCount = 0;
|
|
||||||
var favCount = 0;
|
|
||||||
|
|
||||||
for (final item in items) {
|
|
||||||
final isFav = item.mediaService.mediaFile.isFavorite;
|
|
||||||
if (!_filterFavoritesOnly || isFav) {
|
|
||||||
visibleCount++;
|
|
||||||
}
|
|
||||||
if (_selectedMediaIds.contains(
|
|
||||||
item.mediaService.mediaFile.mediaId,
|
|
||||||
)) {
|
|
||||||
if (isFav) {
|
|
||||||
favCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final areAllSelected =
|
|
||||||
visibleCount > 0 &&
|
|
||||||
_selectedMediaIds.length >= visibleCount;
|
|
||||||
final areAllFav =
|
|
||||||
_selectedMediaIds.isNotEmpty &&
|
|
||||||
favCount == _selectedMediaIds.length;
|
|
||||||
|
|
||||||
return MemoriesSelectionToolbarComp(
|
|
||||||
selectedCount: _selectedMediaIds.length,
|
|
||||||
areAllSelected: areAllSelected,
|
|
||||||
areAllFav: areAllFav,
|
|
||||||
onSelectAll: _selectAll,
|
|
||||||
onExport: _batchExport,
|
|
||||||
onFavorite: _batchFavorite,
|
|
||||||
onDelete: _batchDelete,
|
|
||||||
onClear: () => setState(_selectedMediaIds.clear),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ import 'dart:math';
|
||||||
import 'package:drift/drift.dart' show Value;
|
import 'package:drift/drift.dart' show Value;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
|
@ -10,11 +9,11 @@ import 'package:twonly/src/model/memory_item.model.dart';
|
||||||
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
|
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
import 'package:twonly/src/visual/helpers/video_player_file.helper.dart';
|
|
||||||
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
|
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
|
||||||
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart';
|
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_item.comp.dart';
|
||||||
|
|
||||||
class SynchronizedImageViewerScreen extends StatefulWidget {
|
class SynchronizedImageViewerScreen extends StatefulWidget {
|
||||||
const SynchronizedImageViewerScreen({
|
const SynchronizedImageViewerScreen({
|
||||||
|
|
@ -43,6 +42,7 @@ class _SynchronizedImageViewerScreenState
|
||||||
final Set<String> _favoritedMediaIds = {};
|
final Set<String> _favoritedMediaIds = {};
|
||||||
bool _isSaving = false;
|
bool _isSaving = false;
|
||||||
final Set<String> _storedMediaIds = {};
|
final Set<String> _storedMediaIds = {};
|
||||||
|
final Set<String> _precachedMediaIds = {};
|
||||||
|
|
||||||
late int _currentIndex;
|
late int _currentIndex;
|
||||||
bool _isZoomed = false;
|
bool _isZoomed = false;
|
||||||
|
|
@ -56,18 +56,24 @@ class _SynchronizedImageViewerScreenState
|
||||||
final initialId =
|
final initialId =
|
||||||
widget.galleryItems[widget.initialIndex].mediaService.mediaFile.mediaId;
|
widget.galleryItems[widget.initialIndex].mediaService.mediaFile.mediaId;
|
||||||
_currentlyViewedMediaIdNotifier = ValueNotifier(initialId);
|
_currentlyViewedMediaIdNotifier = ValueNotifier(initialId);
|
||||||
|
_precachedMediaIds.add(initialId);
|
||||||
|
|
||||||
_horizontalPager = PageController(initialPage: widget.initialIndex);
|
_horizontalPager = PageController(initialPage: widget.initialIndex);
|
||||||
_verticalPager = PageController(initialPage: 1);
|
_verticalPager = PageController(initialPage: 1);
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
|
if (mounted) {
|
||||||
|
_verticalPager.addListener(_onVerticalScrollUpdated);
|
||||||
|
_precacheNeighbours(_currentIndex);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
for (final item in widget.galleryItems) {
|
for (final item in widget.galleryItems) {
|
||||||
if (item.mediaService.mediaFile.isFavorite) {
|
if (item.mediaService.mediaFile.isFavorite) {
|
||||||
_favoritedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
_favoritedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
||||||
}
|
}
|
||||||
if (item.mediaService.storedPath.existsSync()) {
|
if (item.mediaService.mediaFile.stored ||
|
||||||
|
(item.mediaService.storedPath.existsSync() &&
|
||||||
|
item.mediaService.storedPath.lengthSync() > 0)) {
|
||||||
_storedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
_storedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -156,36 +162,46 @@ class _SynchronizedImageViewerScreenState
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteFile() async {
|
Future<void> _deleteFile() async {
|
||||||
final confirmed = await showAlertDialog(
|
final item = widget.galleryItems[_currentIndex];
|
||||||
context,
|
final mediaId = item.mediaService.mediaFile.mediaId;
|
||||||
context.lang.deleteImageTitle,
|
final hasCloudBackup =
|
||||||
context.lang.deleteImageBody,
|
item.mediaService.mediaFile.cloudState != CloudState.none;
|
||||||
|
|
||||||
|
final deleteCompletely = await showDeleteMemoriesDialog(
|
||||||
|
context: context,
|
||||||
|
count: 1,
|
||||||
|
hasCloudBackup: hasCloudBackup,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (deleteCompletely == null) return;
|
||||||
|
|
||||||
widget.galleryItems[_currentIndex].mediaService.fullMediaRemoval();
|
if (deleteCompletely) {
|
||||||
await twonlyDB.mediaFilesDao.deleteMediaFile(
|
item.mediaService.fullMediaRemoval();
|
||||||
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId,
|
await apiService.deleteMemory(mediaId);
|
||||||
);
|
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
||||||
|
|
||||||
widget.galleryItems.removeAt(_currentIndex);
|
widget.galleryItems.removeAt(_currentIndex);
|
||||||
|
|
||||||
if (widget.galleryItems.isEmpty) {
|
if (widget.galleryItems.isEmpty) {
|
||||||
if (mounted) Navigator.pop(context, true);
|
if (mounted) Navigator.pop(context, true);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentIndex >= widget.galleryItems.length) {
|
||||||
|
_currentIndex = widget.galleryItems.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newId =
|
||||||
|
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId;
|
||||||
|
_currentlyViewedMediaIdNotifier.value = newId;
|
||||||
|
widget.activeMediaIdNotifier.value = newId;
|
||||||
|
|
||||||
|
setState(() {});
|
||||||
|
} else {
|
||||||
|
if (item.mediaService.storedPath.existsSync()) {
|
||||||
|
item.mediaService.storedPath.deleteSync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_currentIndex >= widget.galleryItems.length) {
|
|
||||||
_currentIndex = widget.galleryItems.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
final newId =
|
|
||||||
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId;
|
|
||||||
_currentlyViewedMediaIdNotifier.value = newId;
|
|
||||||
widget.activeMediaIdNotifier.value = newId;
|
|
||||||
|
|
||||||
setState(() {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _exportFile() async {
|
Future<void> _exportFile() async {
|
||||||
|
|
@ -305,88 +321,54 @@ class _SynchronizedImageViewerScreenState
|
||||||
.mediaId;
|
.mediaId;
|
||||||
_currentlyViewedMediaIdNotifier.value = newMediaId;
|
_currentlyViewedMediaIdNotifier.value = newMediaId;
|
||||||
widget.activeMediaIdNotifier.value = newMediaId;
|
widget.activeMediaIdNotifier.value = newMediaId;
|
||||||
|
_precachedMediaIds.add(newMediaId);
|
||||||
|
_precacheNeighbours(idx);
|
||||||
},
|
},
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = widget.galleryItems[index];
|
return SynchronizedViewerItemComp(
|
||||||
final itemMediaId = item.mediaService.mediaFile.mediaId;
|
item: widget.galleryItems[index],
|
||||||
|
currentlyViewedMediaIdNotifier:
|
||||||
var filePath = item.mediaService.storedPath;
|
_currentlyViewedMediaIdNotifier,
|
||||||
if (!filePath.existsSync()) {
|
onZoomChanged: (zoomed) {
|
||||||
filePath = item.mediaService.tempPath;
|
if (_isZoomed != zoomed) {
|
||||||
}
|
setState(() {
|
||||||
|
_isZoomed = zoomed;
|
||||||
final isVideo =
|
});
|
||||||
item.mediaService.mediaFile.type == MediaType.video;
|
}
|
||||||
|
},
|
||||||
return Center(
|
|
||||||
child: ValueListenableBuilder<String>(
|
|
||||||
valueListenable: _currentlyViewedMediaIdNotifier,
|
|
||||||
builder: (context, activeMediaId, childWidget) {
|
|
||||||
// Dynamically resolve Hero tags to prevent layout tree duplicate assertions
|
|
||||||
final isActiveTarget = activeMediaId == itemMediaId;
|
|
||||||
|
|
||||||
if (isActiveTarget) {
|
|
||||||
return Hero(
|
|
||||||
tag: itemMediaId,
|
|
||||||
transitionOnUserGestures: true,
|
|
||||||
child: childWidget!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return childWidget!;
|
|
||||||
},
|
|
||||||
child: !filePath.existsSync()
|
|
||||||
? const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.broken_image_outlined,
|
|
||||||
color: Colors.white38,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: isVideo
|
|
||||||
? VideoPlayerFileHelper(videoPath: filePath)
|
|
||||||
: PhotoView(
|
|
||||||
imageProvider: FileImage(filePath),
|
|
||||||
initialScale:
|
|
||||||
PhotoViewComputedScale.contained,
|
|
||||||
minScale: PhotoViewComputedScale.contained,
|
|
||||||
maxScale:
|
|
||||||
PhotoViewComputedScale.covered * 4.1,
|
|
||||||
backgroundDecoration: const BoxDecoration(
|
|
||||||
color: Colors.transparent,
|
|
||||||
),
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return const Center(
|
|
||||||
child: Icon(
|
|
||||||
Icons.broken_image_outlined,
|
|
||||||
color: Colors.white38,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
scaleStateChangedCallback: (state) {
|
|
||||||
final zoomed =
|
|
||||||
state != PhotoViewScaleState.initial;
|
|
||||||
if (_isZoomed != zoomed) {
|
|
||||||
setState(() {
|
|
||||||
_isZoomed = zoomed;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
SynchronizedViewerActionsToolbarComp(
|
Positioned(
|
||||||
isFavorite: _favoritedMediaIds.contains(currentMediaId),
|
bottom: MediaQuery.paddingOf(context).bottom + 16,
|
||||||
onShare: _shareMediaFile,
|
left: 0,
|
||||||
onExport: _exportFile,
|
right: 0,
|
||||||
onToggleFavorite: () => _toggleFavorite(currentMediaId),
|
child: AnimatedOpacity(
|
||||||
onDelete: _deleteFile,
|
opacity: _isZoomed ? 0.0 : 1.0,
|
||||||
showStoreButton: !_storedMediaIds.contains(currentMediaId),
|
duration: const Duration(milliseconds: 200),
|
||||||
onStore: _storeMediaFile,
|
curve: Curves.easeInOut,
|
||||||
isImageSaving: _isSaving,
|
child: IgnorePointer(
|
||||||
|
ignoring: _isZoomed,
|
||||||
|
child: Center(
|
||||||
|
child: SynchronizedViewerActionsToolbarComp(
|
||||||
|
isFavorite: _favoritedMediaIds.contains(
|
||||||
|
currentMediaId,
|
||||||
|
),
|
||||||
|
onShare: _shareMediaFile,
|
||||||
|
onExport: _exportFile,
|
||||||
|
onToggleFavorite: () =>
|
||||||
|
_toggleFavorite(currentMediaId),
|
||||||
|
onDelete: _deleteFile,
|
||||||
|
showStoreButton: !_storedMediaIds.contains(
|
||||||
|
currentMediaId,
|
||||||
|
),
|
||||||
|
onStore: _storeMediaFile,
|
||||||
|
isImageSaving: _isSaving,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -396,4 +378,40 @@ class _SynchronizedImageViewerScreenState
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _precacheNeighbours(int index) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final indicesToPrecache = [index - 5, index + 5];
|
||||||
|
|
||||||
|
for (final idx in indicesToPrecache) {
|
||||||
|
if (idx >= 0 && idx < widget.galleryItems.length) {
|
||||||
|
final item = widget.galleryItems[idx];
|
||||||
|
if (item.mediaService.mediaFile.type == MediaType.video) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final mediaId = item.mediaService.mediaFile.mediaId;
|
||||||
|
if (_precachedMediaIds.contains(mediaId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final filePath =
|
||||||
|
item.mediaService.storedPath.existsSync() &&
|
||||||
|
item.mediaService.storedPath.lengthSync() > 0
|
||||||
|
? item.mediaService.storedPath
|
||||||
|
: item.mediaService.tempPath.existsSync() &&
|
||||||
|
item.mediaService.tempPath.lengthSync() > 0
|
||||||
|
? item.mediaService.tempPath
|
||||||
|
: item.mediaService.thumbnailPath;
|
||||||
|
|
||||||
|
if (filePath.existsSync() && filePath.lengthSync() > 0) {
|
||||||
|
_precachedMediaIds.add(mediaId);
|
||||||
|
precacheImage(FileImage(filePath), context).onError((e, s) {
|
||||||
|
_precachedMediaIds.remove(mediaId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
@ -168,23 +167,22 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
||||||
: Text(context.lang.twonlySafeRecoverBtn),
|
: Text(context.lang.twonlySafeRecoverBtn),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (kDebugMode)
|
MyButton(
|
||||||
MyButton(
|
variant: MyButtonVariant.secondary,
|
||||||
variant: MyButtonVariant.secondary,
|
onPressed: () async {
|
||||||
onPressed: () async {
|
await KeyValueStore.update<OnboardingState>(
|
||||||
await KeyValueStore.update<OnboardingState>(
|
key: KeyValueKeys.onboardingState,
|
||||||
key: KeyValueKeys.onboardingState,
|
update: (state) =>
|
||||||
update: (state) =>
|
state.hasStartedPasswordlessRecovery = true,
|
||||||
state.hasStartedPasswordlessRecovery = true,
|
);
|
||||||
);
|
if (context.mounted) {
|
||||||
if (context.mounted) {
|
await context.push(Routes.recoverPasswordless);
|
||||||
await context.push(Routes.recoverPasswordless);
|
}
|
||||||
}
|
},
|
||||||
},
|
child: Text(
|
||||||
child: Text(
|
context.lang.passwordlessRecoveryRecoverBtn,
|
||||||
context.lang.passwordlessRecoveryRecoverBtn,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:cryptography_plus/cryptography_plus.dart'
|
import 'package:cryptography_plus/cryptography_plus.dart'
|
||||||
show Hmac, Mac, SecretBox, SecretKey, Xchacha20;
|
show Hkdf, Hmac, Mac, SecretBox, SecretKey, Xchacha20;
|
||||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -21,11 +21,13 @@ import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart
|
||||||
import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery.pb.dart';
|
import 'package:twonly/src/model/protobuf/client/generated/passwordless_recovery.pb.dart';
|
||||||
import 'package:twonly/src/services/backup.service.dart';
|
import 'package:twonly/src/services/backup.service.dart';
|
||||||
import 'package:twonly/src/services/passwordless_recovery.service.dart';
|
import 'package:twonly/src/services/passwordless_recovery.service.dart';
|
||||||
|
import 'package:twonly/src/utils/avatars.dart';
|
||||||
import 'package:twonly/src/utils/keyvalue.dart';
|
import 'package:twonly/src/utils/keyvalue.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
import 'package:twonly/src/visual/components/avatar_icon.comp.dart';
|
||||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/contact_chip.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||||
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
import 'package:twonly/src/visual/elements/my_input.element.dart';
|
||||||
import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart';
|
import 'package:twonly/src/visual/views/onboarding/components/animated_bell_icon.comp.dart';
|
||||||
|
|
@ -59,26 +61,21 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_secondFactorController.text = widget.initialEmailToken ?? '';
|
final token =
|
||||||
|
widget.initialEmailToken ?? PasswordlessRecoveryService.lastEmailToken;
|
||||||
|
_secondFactorController.text = token ?? '';
|
||||||
|
|
||||||
_emailTokenSubscription = PasswordlessRecoveryService
|
_emailTokenSubscription = PasswordlessRecoveryService
|
||||||
.onEmailTokenReceived
|
.onEmailTokenReceived
|
||||||
.stream
|
.stream
|
||||||
.listen((token) async {
|
.listen((token) async {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final state = _onboardingState;
|
|
||||||
if (state != null && !state.emailRecoveryRequested) {
|
|
||||||
state.emailRecoveryRequested = true;
|
|
||||||
await KeyValueStore.update<OnboardingState>(
|
|
||||||
key: KeyValueKeys.onboardingState,
|
|
||||||
update: (s) => s.emailRecoveryRequested = true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_secondFactorController.text = token;
|
_secondFactorController.text = token;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_initAsync();
|
_initAsync(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -89,14 +86,14 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initAsync() async {
|
Future<void> _initAsync(String? initialToken) async {
|
||||||
try {
|
try {
|
||||||
// 1. Load OnboardingState
|
// 1. Load OnboardingState
|
||||||
final state = await KeyValueStore.getModel<OnboardingState>(
|
final state = await KeyValueStore.getModel<OnboardingState>(
|
||||||
KeyValueKeys.onboardingState,
|
KeyValueKeys.onboardingState,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (widget.initialEmailToken != null && !state.emailRecoveryRequested) {
|
if (initialToken != null && !state.emailRecoveryRequested) {
|
||||||
state.emailRecoveryRequested = true;
|
state.emailRecoveryRequested = true;
|
||||||
await KeyValueStore.update<OnboardingState>(
|
await KeyValueStore.update<OnboardingState>(
|
||||||
key: KeyValueKeys.onboardingState,
|
key: KeyValueKeys.onboardingState,
|
||||||
|
|
@ -243,7 +240,8 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
final userId = shares.first.myUserId;
|
final userId = shares.first.myUserId;
|
||||||
Uint8List? serverKey;
|
Uint8List? serverKey;
|
||||||
|
|
||||||
if (reconstructed.hasPinSeed()) {
|
if (!reconstructed.hasEmailHint() &&
|
||||||
|
reconstructed.hasServerKeyProtection()) {
|
||||||
final pin = _secondFactorController.text.trim();
|
final pin = _secondFactorController.text.trim();
|
||||||
if (pin.isEmpty) {
|
if (pin.isEmpty) {
|
||||||
showSnackbar(
|
showSnackbar(
|
||||||
|
|
@ -256,18 +254,18 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate pinProtectionKey
|
// Calculate pinProtectionKey via Hkdf
|
||||||
final pinProtectionKey = await Hmac.sha256().calculateMac(
|
final hkdf = Hkdf(hmac: Hmac.sha256(), outputLength: 32);
|
||||||
Uint8List.fromList(utf8.encode(pin)),
|
final pinKey = await hkdf.deriveKey(
|
||||||
secretKey: SecretKey(reconstructed.pinSeed),
|
secretKey: SecretKey(reconstructed.serverKeyProtection),
|
||||||
|
nonce: utf8.encode(pin),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch serverKey
|
// Fetch serverKey
|
||||||
final res = await apiService.getServerKeyForPasswordlessRecovery(
|
final res = await apiService.getServerKeyForPasswordlessRecovery(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
pinUnlockToken: reconstructed.pinUnlockToken,
|
pinUnlockToken: reconstructed.pinUnlockToken,
|
||||||
pinProtectionKey: pinProtectionKey.bytes,
|
pinProtectionKey: await pinKey.extractBytes(),
|
||||||
encryptedServerKeyNone: reconstructed.encryptedServerKeyNonce,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.isError) {
|
if (res.isError) {
|
||||||
|
|
@ -307,7 +305,7 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
final res = await apiService.getServerKeyForPasswordlessRecovery(
|
final res = await apiService.getServerKeyForPasswordlessRecovery(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
email: email,
|
email: email,
|
||||||
encryptedServerKeyNone: reconstructed.encryptedServerKeyNonce,
|
serverKeyProtection: reconstructed.serverKeyProtection,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (res.isError) {
|
if (res.isError) {
|
||||||
|
|
@ -337,7 +335,7 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
showSnackbar(
|
showSnackbar(
|
||||||
context,
|
context,
|
||||||
context.lang.passwordlessRecoveryShareSent,
|
context.lang.passwordlessRecoveryEmailSent,
|
||||||
level: SnackbarLevel.success,
|
level: SnackbarLevel.success,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -494,7 +492,9 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
AvatarIcon(
|
AvatarIcon(
|
||||||
svg: utf8.decode(first.myAvatarSvg ?? []),
|
svg: first.myAvatarSvg != null
|
||||||
|
? getAvatarSvg(Uint8List.fromList(first.myAvatarSvg!))
|
||||||
|
: null,
|
||||||
fontSize: 60,
|
fontSize: 60,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
@ -511,29 +511,26 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.lang.recoverPasswordlessSharesReceived(
|
context.lang.recoverPasswordlessSharesReceived(
|
||||||
shares.length,
|
shares.length,
|
||||||
threshold,
|
threshold,
|
||||||
),
|
),
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
),
|
fontWeight: FontWeight.w600,
|
||||||
const Spacer(),
|
|
||||||
if (thresholdReached)
|
|
||||||
const Icon(
|
|
||||||
Icons.check_circle_rounded,
|
|
||||||
color: Colors.green,
|
|
||||||
size: 20,
|
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 12),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: shares.length / threshold,
|
value: shares.length / threshold,
|
||||||
minHeight: 8,
|
minHeight: 12,
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
? Colors.white.withValues(alpha: 0.1)
|
? Colors.white.withValues(alpha: 0.1)
|
||||||
: Colors.black.withValues(alpha: 0.08),
|
: Colors.black.withValues(alpha: 0.08),
|
||||||
|
|
@ -542,16 +539,17 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 24),
|
||||||
|
Wrap(
|
||||||
...shares.map(
|
spacing: 8,
|
||||||
(share) => Padding(
|
runSpacing: 8,
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
alignment: WrapAlignment.center,
|
||||||
child: Text(
|
children: shares.map((share) {
|
||||||
share.trustedFriendDisplayName,
|
return ContactChip(
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
username: share.trustedFriendDisplayName,
|
||||||
),
|
avatarSvg: share.trustedFriendAvatarSvg,
|
||||||
),
|
);
|
||||||
|
}).toList(),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
@ -574,7 +572,8 @@ class _RecoverPasswordlessState extends State<RecoverPasswordless> {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_reconstructedSecret != null) ...[
|
else if (_reconstructedSecret != null) ...[
|
||||||
if (_reconstructedSecret!.hasPinSeed()) ...[
|
if (_reconstructedSecret!.hasServerKeyProtection() &&
|
||||||
|
!_reconstructedSecret!.hasEmailHint()) ...[
|
||||||
MyInput(
|
MyInput(
|
||||||
controller: _secondFactorController,
|
controller: _secondFactorController,
|
||||||
hintText: context.lang.passwordlessRecoveryMethodPinHint,
|
hintText: context.lang.passwordlessRecoveryMethodPinHint,
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@ class _AppearanceViewState extends State<AppearanceView> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleShowFeedbackIcon() async {
|
Future<void> toggleShowNewsIcon() async {
|
||||||
await UserService.update((u) {
|
await UserService.update((u) {
|
||||||
u.showFeedbackShortcut = !u.showFeedbackShortcut;
|
u.showNewsShortcut = !u.showNewsShortcut;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -113,11 +113,11 @@ class _AppearanceViewState extends State<AppearanceView> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: Text(context.lang.contactUsShortcut),
|
title: Text(context.lang.hideNewsIcon),
|
||||||
onTap: toggleShowFeedbackIcon,
|
onTap: toggleShowNewsIcon,
|
||||||
trailing: Switch.adaptive(
|
trailing: Switch.adaptive(
|
||||||
value: !userService.currentUser.showFeedbackShortcut,
|
value: !userService.currentUser.showNewsShortcut,
|
||||||
onChanged: (a) => toggleShowFeedbackIcon(),
|
onChanged: (a) => toggleShowNewsIcon(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
|
@ -186,15 +185,14 @@ class _BackupViewState extends State<BackupView> {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
if (userService.currentUser.passwordLessRecovery == null &&
|
if (userService.currentUser.passwordLessRecovery == null) ...[
|
||||||
kDebugMode) ...[
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Center(
|
Center(
|
||||||
child: MyButton(
|
child: MyButton(
|
||||||
variant: MyButtonVariant.primaryMiddle,
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
context.navPush(const PasswordLessRecoverySetup()),
|
context.navPush(const PasswordLessRecoverySetup()),
|
||||||
child: const Text('Setup Passwordless Recovery'),
|
child: Text(context.lang.passwordlessRecoveryEnableBtn),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||||
|
import 'package:twonly/src/visual/views/settings/backup/passwordless_recovery/setup.passwordless_recovery.view.dart';
|
||||||
|
|
||||||
|
class MissingRecoveryContactsComp extends StatefulWidget {
|
||||||
|
const MissingRecoveryContactsComp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MissingRecoveryContactsComp> createState() =>
|
||||||
|
_MissingRecoveryContactsCompState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MissingRecoveryContactsCompState
|
||||||
|
extends State<MissingRecoveryContactsComp> {
|
||||||
|
bool _hasEnoughFriends = false;
|
||||||
|
bool _loading = true;
|
||||||
|
StreamSubscription<List<Contact>>? _contactsSub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkFriends();
|
||||||
|
_contactsSub = twonlyDB.contactsDao.watchAllAcceptedContacts().listen((_) {
|
||||||
|
_checkFriends();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_contactsSub?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkFriends() async {
|
||||||
|
final user = userService.currentUser;
|
||||||
|
if (user.currentSetupPage != null || user.passwordLessRecovery != null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final kvs = await twonlyDB.select(twonlyDB.keyVerifications).get();
|
||||||
|
final urs = await (twonlyDB.select(
|
||||||
|
twonlyDB.userDiscoveryUserRelations,
|
||||||
|
)..where((u) => u.publicKeyVerifiedTimestamp.isNotNull())).get();
|
||||||
|
|
||||||
|
final verified = {
|
||||||
|
...kvs.map((row) => row.contactId),
|
||||||
|
...urs.map((row) => row.announcedUserId),
|
||||||
|
};
|
||||||
|
|
||||||
|
final contacts = await twonlyDB.contactsDao.getAllContacts();
|
||||||
|
var count = 0;
|
||||||
|
for (final c in contacts) {
|
||||||
|
if (verified.contains(c.userId) &&
|
||||||
|
c.accepted &&
|
||||||
|
!c.blocked &&
|
||||||
|
!c.accountDeleted &&
|
||||||
|
!c.deletedByUser) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_hasEnoughFriends = count >= 4;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onTap() async {
|
||||||
|
await context.navPush(const PasswordLessRecoverySetup());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StreamBuilder<void>(
|
||||||
|
stream: userService.onUserUpdated,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final user = userService.currentUser;
|
||||||
|
|
||||||
|
if (user.currentSetupPage != null ||
|
||||||
|
user.passwordLessRecovery != null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_loading || !_hasEnoughFriends) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
context.color.secondaryContainer.withValues(alpha: 0.2),
|
||||||
|
context.color.secondaryContainer.withValues(alpha: 0.1),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: context.color.secondary.withValues(alpha: 0.15),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: context.color.shadow.withValues(alpha: 0.05),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: context.color.secondary.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: FaIcon(
|
||||||
|
FontAwesomeIcons.users,
|
||||||
|
size: 28,
|
||||||
|
color: context.color.secondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 20),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.lang.missingRecoveryContactsCardTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontSize: 16,
|
||||||
|
color: context.color.onSurface,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
context.lang.missingRecoveryContactsCardDesc,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: context.color.onSurfaceVariant,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
MyButton(
|
||||||
|
onPressed: onTap,
|
||||||
|
variant: MyButtonVariant.primaryDense,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_forward_rounded,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
context
|
||||||
|
.lang
|
||||||
|
.missingRecoveryContactsCardAction,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,16 +17,16 @@ class _FactorOption {
|
||||||
|
|
||||||
const _options = [
|
const _options = [
|
||||||
_FactorOption(
|
_FactorOption(
|
||||||
type: SecondFactorType.none,
|
type: SecondFactorType.email,
|
||||||
icon: Icons.block_rounded,
|
icon: Icons.email_rounded,
|
||||||
),
|
),
|
||||||
_FactorOption(
|
_FactorOption(
|
||||||
type: SecondFactorType.pin,
|
type: SecondFactorType.pin,
|
||||||
icon: Icons.dialpad_rounded,
|
icon: Icons.dialpad_rounded,
|
||||||
),
|
),
|
||||||
_FactorOption(
|
_FactorOption(
|
||||||
type: SecondFactorType.email,
|
type: SecondFactorType.none,
|
||||||
icon: Icons.email_rounded,
|
icon: Icons.block_rounded,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -285,6 +285,22 @@ class _ConfirmRecoveryDialogState extends State<ConfirmRecoveryDialog> {
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
AvatarIcon(contactId: widget.contact.userId),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
getContactDisplayName(widget.contact),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
context.lang.passwordlessRecoveryVerifySourceDesc,
|
context.lang.passwordlessRecoveryVerifySourceDesc,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|
|
||||||
|
|
@ -93,172 +93,187 @@ class PasswordLessRecoverySettings extends StatelessWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final config = userService.currentUser.passwordLessRecovery;
|
return StreamBuilder<void>(
|
||||||
|
stream: userService.onUserUpdated,
|
||||||
|
builder: (context, _) {
|
||||||
|
final config = userService.currentUser.passwordLessRecovery;
|
||||||
|
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(context.lang.passwordlessRecovery)),
|
appBar: AppBar(title: Text(context.lang.passwordlessRecovery)),
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Text(context.lang.passwordlessRecoveryNotConfigured),
|
child: Text(context.lang.passwordlessRecoveryNotConfigured),
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
var secondFactorLabel = context.lang.passwordlessRecoverySecondFactorNone;
|
|
||||||
var secondFactorIcon = Icons.lock_outline_rounded;
|
|
||||||
Widget? actionButton;
|
|
||||||
|
|
||||||
if (config.email != null) {
|
|
||||||
secondFactorLabel = context.lang
|
|
||||||
.passwordlessRecoverySecondFactorEmailLabel(config.email!);
|
|
||||||
secondFactorIcon = Icons.email_outlined;
|
|
||||||
} else if (config.pinSeed != null) {
|
|
||||||
secondFactorLabel = context.lang.passwordlessRecoverySecondFactorPin;
|
|
||||||
secondFactorIcon = Icons.pin_outlined;
|
|
||||||
actionButton = MyButton(
|
|
||||||
variant: MyButtonVariant.secondaryDense,
|
|
||||||
onPressed: () => _testPin(context),
|
|
||||||
child: Text(context.lang.passwordlessRecoveryTestPin),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: Text(context.lang.passwordlessRecovery),
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
onPressed: () => showPasswordlessRecoveryInfoSheet(context),
|
|
||||||
icon: const Icon(Icons.info_outline_rounded),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: ListView(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
children: [
|
|
||||||
MyButton(
|
|
||||||
variant: MyButtonVariant.secondary,
|
|
||||||
onPressed: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const PasswordLessRecoverySetup(),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
);
|
||||||
mainAxisSize: MainAxisSize.min,
|
}
|
||||||
children: [
|
|
||||||
Row(
|
var secondFactorLabel =
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
context.lang.passwordlessRecoverySecondFactorNone;
|
||||||
|
var secondFactorIcon = Icons.lock_outline_rounded;
|
||||||
|
Widget? actionButton;
|
||||||
|
|
||||||
|
if (config.email != null) {
|
||||||
|
secondFactorLabel = context.lang
|
||||||
|
.passwordlessRecoverySecondFactorEmailLabel(config.email!);
|
||||||
|
secondFactorIcon = Icons.email_outlined;
|
||||||
|
} else if (config.serverKeyProtection != null && config.email == null) {
|
||||||
|
secondFactorLabel = context.lang.passwordlessRecoverySecondFactorPin;
|
||||||
|
secondFactorIcon = Icons.pin_outlined;
|
||||||
|
actionButton = MyButton(
|
||||||
|
variant: MyButtonVariant.secondaryDense,
|
||||||
|
onPressed: () => _testPin(context),
|
||||||
|
child: Text(context.lang.passwordlessRecoveryTestPin),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(context.lang.passwordlessRecovery),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => showPasswordlessRecoveryInfoSheet(context),
|
||||||
|
icon: const Icon(Icons.info_outline_rounded),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
MyButton(
|
||||||
|
variant: MyButtonVariant.secondary,
|
||||||
|
onPressed: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => const PasswordLessRecoverySetup(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.manage_accounts_rounded, size: 18),
|
Row(
|
||||||
const SizedBox(width: 8),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.manage_accounts_rounded, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
context.lang.passwordlessRecoveryModify,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
context.lang.passwordlessRecoveryModify,
|
context.lang.passwordlessRecoveryModifyDesc,
|
||||||
style: const TextStyle(fontSize: 14),
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: context.color.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
),
|
||||||
Text(
|
const SizedBox(height: 24),
|
||||||
context.lang.passwordlessRecoveryModifyDesc,
|
_buildInfoCard(
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
context,
|
||||||
color: context.color.onSurfaceVariant,
|
title: context.lang.passwordlessRecoverySecondFactor,
|
||||||
fontSize: 12,
|
value: secondFactorLabel,
|
||||||
),
|
icon: secondFactorIcon,
|
||||||
textAlign: TextAlign.center,
|
action: actionButton,
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 24),
|
||||||
),
|
StreamBuilder<List<Contact>>(
|
||||||
|
stream:
|
||||||
|
(twonlyDB.select(
|
||||||
|
twonlyDB.contacts,
|
||||||
|
)..where((t) => t.recoveryIsTrustedFriend.equals(true)))
|
||||||
|
.watch(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator.adaptive(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final friends = snapshot.data!;
|
||||||
|
if (friends.isEmpty) {
|
||||||
|
return Text(
|
||||||
|
context.lang.passwordlessRecoveryNoFriendsFound,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final activeFriends = friends.where((c) {
|
||||||
|
final lastHeartbeat = c.recoveryLastHeartbeat;
|
||||||
|
if (lastHeartbeat == null) return false;
|
||||||
|
return DateTime.now().difference(lastHeartbeat).inDays <=
|
||||||
|
14;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final inactiveFriends = friends.where((c) {
|
||||||
|
final lastHeartbeat = c.recoveryLastHeartbeat;
|
||||||
|
if (lastHeartbeat == null) return true;
|
||||||
|
return DateTime.now().difference(lastHeartbeat).inDays > 14;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (activeFriends.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
context.lang.passwordlessRecoveryActiveFriends,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
context.lang.passwordlessRecoveryActiveFriendsDesc,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: context.color.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildFriendsGridCard(
|
||||||
|
context,
|
||||||
|
friends: activeFriends,
|
||||||
|
isActive: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
if (inactiveFriends.isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
context.lang.passwordlessRecoveryInactiveFriends,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
context.lang.passwordlessRecoveryInactiveFriendsDesc,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: context.color.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildFriendsGridCard(
|
||||||
|
context,
|
||||||
|
friends: inactiveFriends,
|
||||||
|
isActive: false,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
);
|
||||||
_buildInfoCard(
|
},
|
||||||
context,
|
|
||||||
title: context.lang.passwordlessRecoverySecondFactor,
|
|
||||||
value: secondFactorLabel,
|
|
||||||
icon: secondFactorIcon,
|
|
||||||
action: actionButton,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
StreamBuilder<List<Contact>>(
|
|
||||||
stream: (twonlyDB.select(
|
|
||||||
twonlyDB.contacts,
|
|
||||||
)..where((t) => t.recoveryIsTrustedFriend.equals(true))).watch(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (!snapshot.hasData) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator.adaptive(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final friends = snapshot.data!;
|
|
||||||
if (friends.isEmpty) {
|
|
||||||
return Text(context.lang.passwordlessRecoveryNoFriendsFound);
|
|
||||||
}
|
|
||||||
|
|
||||||
final activeFriends = friends.where((c) {
|
|
||||||
final lastHeartbeat = c.recoveryLastHeartbeat;
|
|
||||||
if (lastHeartbeat == null) return false;
|
|
||||||
return DateTime.now().difference(lastHeartbeat).inDays <= 14;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
final inactiveFriends = friends.where((c) {
|
|
||||||
final lastHeartbeat = c.recoveryLastHeartbeat;
|
|
||||||
if (lastHeartbeat == null) return true;
|
|
||||||
return DateTime.now().difference(lastHeartbeat).inDays > 14;
|
|
||||||
}).toList();
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
if (activeFriends.isNotEmpty) ...[
|
|
||||||
Text(
|
|
||||||
context.lang.passwordlessRecoveryActiveFriends,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
context.lang.passwordlessRecoveryActiveFriendsDesc,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: context.color.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildFriendsGridCard(
|
|
||||||
context,
|
|
||||||
friends: activeFriends,
|
|
||||||
isActive: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
|
||||||
if (inactiveFriends.isNotEmpty) ...[
|
|
||||||
Text(
|
|
||||||
context.lang.passwordlessRecoveryInactiveFriends,
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
context.lang.passwordlessRecoveryInactiveFriendsDesc,
|
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
||||||
color: context.color.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildFriendsGridCard(
|
|
||||||
context,
|
|
||||||
friends: inactiveFriends,
|
|
||||||
isActive: false,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
|
@ -69,15 +68,17 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
if (config.email != null) {
|
if (config.email != null) {
|
||||||
_secondFactor = SecondFactorType.email;
|
_secondFactor = SecondFactorType.email;
|
||||||
_emailController.text = config.email!;
|
_emailController.text = config.email!;
|
||||||
} else if (config.pinSeed != null) {
|
_threshold = 2;
|
||||||
|
} else if (config.serverKeyProtection != null && config.email == null) {
|
||||||
_secondFactor = SecondFactorType.pin;
|
_secondFactor = SecondFactorType.pin;
|
||||||
|
_threshold = 2;
|
||||||
} else {
|
} else {
|
||||||
_secondFactor = SecondFactorType.none;
|
_secondFactor = SecondFactorType.none;
|
||||||
|
_threshold = 4;
|
||||||
}
|
}
|
||||||
_threshold = max(2, (selectedContacts.length / 2).ceil());
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
contacts.sortBy((c) => c.mediaSendCounter);
|
contacts.sort((a, b) => b.mediaSendCounter.compareTo(a.mediaSendCounter));
|
||||||
final verifiedContacts = contacts
|
final verifiedContacts = contacts
|
||||||
.where(
|
.where(
|
||||||
(c) =>
|
(c) =>
|
||||||
|
|
@ -137,8 +138,9 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => SelectContactsView(
|
builder: (_) => SelectContactsView(
|
||||||
text: SelectedContactView(
|
text: SelectedContactView(
|
||||||
title: 'Trusted Friends',
|
title: context.lang.passwordlessRecoveryTrustedFriends,
|
||||||
submitButton: (selected, _) => 'Done ($selected)',
|
submitButton: (selected, _) =>
|
||||||
|
context.lang.passwordlessRecoveryDoneBtn(selected),
|
||||||
submitIcon: FontAwesomeIcons.check,
|
submitIcon: FontAwesomeIcons.check,
|
||||||
),
|
),
|
||||||
alreadySelected: _selectedContacts.map((c) => c.userId).toList(),
|
alreadySelected: _selectedContacts.map((c) => c.userId).toList(),
|
||||||
|
|
@ -230,7 +232,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
onSelectFriends: _selectTrustedFriends,
|
onSelectFriends: _selectTrustedFriends,
|
||||||
onRemoveContact: (userId) => setState(() {
|
onRemoveContact: (userId) => setState(() {
|
||||||
_selectedContacts.removeWhere((c) => c.userId == userId);
|
_selectedContacts.removeWhere((c) => c.userId == userId);
|
||||||
_threshold = _validThreshold;
|
_threshold = _minThreshold;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
|
@ -239,7 +241,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
selected: _secondFactor,
|
selected: _secondFactor,
|
||||||
onChanged: (type) => setState(() {
|
onChanged: (type) => setState(() {
|
||||||
_secondFactor = type;
|
_secondFactor = type;
|
||||||
_threshold = _validThreshold;
|
_threshold = _minThreshold;
|
||||||
}),
|
}),
|
||||||
pinController: _pinController,
|
pinController: _pinController,
|
||||||
emailController: _emailController,
|
emailController: _emailController,
|
||||||
|
|
@ -250,7 +252,7 @@ class _PasswordLessRecoverySetupState extends State<PasswordLessRecoverySetup> {
|
||||||
ThresholdPicker(
|
ThresholdPicker(
|
||||||
contactCount: _selectedContacts.length,
|
contactCount: _selectedContacts.length,
|
||||||
minThreshold: _minThreshold,
|
minThreshold: _minThreshold,
|
||||||
currentThreshold: _validThreshold,
|
currentThreshold: _threshold,
|
||||||
onChanged: (value) => setState(() => _threshold = value),
|
onChanged: (value) => setState(() => _threshold = value),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 28),
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,16 @@ class ChatReactionSelectionView extends StatefulWidget {
|
||||||
class _ChatReactionSelectionView extends State<ChatReactionSelectionView> {
|
class _ChatReactionSelectionView extends State<ChatReactionSelectionView> {
|
||||||
List<String> _selectedEmojis = [];
|
List<String> _selectedEmojis = [];
|
||||||
|
|
||||||
|
/// Cache the emoji keys list once to avoid repeated Map.keys.toList() calls.
|
||||||
|
static final List<String> _allEmojis = EmojiAnimationComp.animatedIcons.keys
|
||||||
|
.toList();
|
||||||
|
|
||||||
List<String> _emojisFromSession() {
|
List<String> _emojisFromSession() {
|
||||||
final user = userService.currentUser;
|
final user = userService.currentUser;
|
||||||
if (user.preSelectedEmojies != null) {
|
if (user.preSelectedEmojies != null) {
|
||||||
return user.preSelectedEmojies!;
|
return user.preSelectedEmojies!;
|
||||||
}
|
}
|
||||||
return EmojiAnimationComp.animatedIcons.keys.toList().sublist(0, 6);
|
return _allEmojis.sublist(0, 6);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -60,23 +64,27 @@ class _ChatReactionSelectionView extends State<ChatReactionSelectionView> {
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 4,
|
crossAxisCount: 4,
|
||||||
),
|
),
|
||||||
itemCount: EmojiAnimationComp.animatedIcons.keys.length,
|
itemCount: _allEmojis.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final emoji = EmojiAnimationComp.animatedIcons.keys.elementAt(
|
final emoji = _allEmojis[index];
|
||||||
index,
|
final isSelected = _selectedEmojis.contains(emoji);
|
||||||
);
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _onEmojiSelected(emoji),
|
onTap: () => _onEmojiSelected(emoji),
|
||||||
child: Card(
|
child: Card(
|
||||||
color: _selectedEmojis.contains(emoji)
|
color: isSelected
|
||||||
? context.color.primary.withAlpha(150)
|
? context.color.primary.withAlpha(150)
|
||||||
: context.color.surface,
|
: context.color.surface,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SizedBox(
|
child: isSelected
|
||||||
width: 40,
|
? SizedBox(
|
||||||
height: 40,
|
width: 40,
|
||||||
child: EmojiAnimationComp(emoji: emoji),
|
height: 40,
|
||||||
),
|
child: EmojiAnimationComp(emoji: emoji),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
emoji,
|
||||||
|
style: const TextStyle(fontSize: 32),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
|
|
||||||
final total = uris.length;
|
final total = uris.length;
|
||||||
var importedCount = 0;
|
var importedCount = 0;
|
||||||
var duplicated = 0;
|
|
||||||
var failedCount = 0;
|
var failedCount = 0;
|
||||||
|
|
||||||
for (final uri in uris) {
|
for (final uri in uris) {
|
||||||
|
|
@ -87,7 +86,11 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
|
|
||||||
final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||||
if (exists.isNotEmpty) {
|
if (exists.isNotEmpty) {
|
||||||
duplicated += 1;
|
final mediaFile = exists.first;
|
||||||
|
final mediaService = MediaFileService(mediaFile);
|
||||||
|
await mediaService.storedPath.parent.create(recursive: true);
|
||||||
|
await File(mediaService.storedPath.path).writeAsBytes(bytes);
|
||||||
|
importedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -133,7 +136,6 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
context,
|
context,
|
||||||
context.lang.importGalleryComplete(
|
context.lang.importGalleryComplete(
|
||||||
importedCount,
|
importedCount,
|
||||||
duplicated,
|
|
||||||
failedCount,
|
failedCount,
|
||||||
),
|
),
|
||||||
level: SnackbarLevel.success,
|
level: SnackbarLevel.success,
|
||||||
|
|
@ -325,7 +327,6 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
.toList();
|
.toList();
|
||||||
final total = selectedAssets.length;
|
final total = selectedAssets.length;
|
||||||
var importedCount = 0;
|
var importedCount = 0;
|
||||||
var duplicated = 0;
|
|
||||||
var failedCount = 0;
|
var failedCount = 0;
|
||||||
|
|
||||||
for (final asset in selectedAssets) {
|
for (final asset in selectedAssets) {
|
||||||
|
|
@ -348,7 +349,11 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
|
|
||||||
final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
final exists = await twonlyDB.mediaFilesDao.getMediaByHash(hash);
|
||||||
if (exists.isNotEmpty) {
|
if (exists.isNotEmpty) {
|
||||||
duplicated += 1;
|
final mediaFile = exists.first;
|
||||||
|
final mediaService = MediaFileService(mediaFile);
|
||||||
|
await mediaService.storedPath.parent.create(recursive: true);
|
||||||
|
await file.copy(mediaService.storedPath.path);
|
||||||
|
importedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -371,13 +376,17 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
try {
|
try {
|
||||||
final assetName = await asset.titleAsync;
|
final assetName = await asset.titleAsync;
|
||||||
final dotIndex = assetName.lastIndexOf('.');
|
final dotIndex = assetName.lastIndexOf('.');
|
||||||
final baseName = dotIndex != -1 ? assetName.substring(0, dotIndex) : assetName;
|
final baseName = dotIndex != -1
|
||||||
|
? assetName.substring(0, dotIndex)
|
||||||
|
: assetName;
|
||||||
|
|
||||||
final uuidRegex = RegExp(
|
final uuidRegex = RegExp(
|
||||||
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
|
r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$',
|
||||||
);
|
);
|
||||||
if (uuidRegex.hasMatch(baseName)) {
|
if (uuidRegex.hasMatch(baseName)) {
|
||||||
final existing = await twonlyDB.mediaFilesDao.getMediaFileById(baseName);
|
final existing = await twonlyDB.mediaFilesDao.getMediaFileById(
|
||||||
|
baseName,
|
||||||
|
);
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
final mediaService = MediaFileService(existing);
|
final mediaService = MediaFileService(existing);
|
||||||
if (!mediaService.storedPath.existsSync()) {
|
if (!mediaService.storedPath.existsSync()) {
|
||||||
|
|
@ -388,7 +397,9 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
stored: const Value(true),
|
stored: const Value(true),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(baseName);
|
mediaFile = await twonlyDB.mediaFilesDao.getMediaFileById(
|
||||||
|
baseName,
|
||||||
|
);
|
||||||
isRestored = true;
|
isRestored = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -436,7 +447,6 @@ class _ImportFromGalleryViewState extends State<ImportFromGalleryView> {
|
||||||
context,
|
context,
|
||||||
context.lang.importGalleryComplete(
|
context.lang.importGalleryComplete(
|
||||||
importedCount,
|
importedCount,
|
||||||
duplicated,
|
|
||||||
failedCount,
|
failedCount,
|
||||||
),
|
),
|
||||||
level: SnackbarLevel.success,
|
level: SnackbarLevel.success,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,23 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:drift/drift.dart' hide Column;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:twonly/locator.dart';
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/constants/routes.keys.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart'
|
||||||
|
as server;
|
||||||
|
import 'package:twonly/src/providers/purchases.provider.dart';
|
||||||
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:twonly/src/services/memories/memories_cloud.service.dart';
|
||||||
|
import 'package:twonly/src/services/subscription.service.dart';
|
||||||
|
import 'package:twonly/src/services/user.service.dart';
|
||||||
import 'package:twonly/src/utils/misc.dart';
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/my_button.element.dart';
|
||||||
|
import 'package:twonly/src/visual/views/settings/data_and_storage/storage_contents.view.dart';
|
||||||
|
|
||||||
class ManageStorageView extends StatefulWidget {
|
class ManageStorageView extends StatefulWidget {
|
||||||
const ManageStorageView({super.key});
|
const ManageStorageView({super.key});
|
||||||
|
|
@ -12,6 +28,8 @@ class ManageStorageView extends StatefulWidget {
|
||||||
|
|
||||||
class _ManageStorageViewState extends State<ManageStorageView> {
|
class _ManageStorageViewState extends State<ManageStorageView> {
|
||||||
Map<MediaType, int> _stats = {};
|
Map<MediaType, int> _stats = {};
|
||||||
|
server.Response_MemoriesUsage? _memoriesUsage;
|
||||||
|
int _cloudOnlyCount = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -21,15 +39,115 @@ class _ManageStorageViewState extends State<ManageStorageView> {
|
||||||
|
|
||||||
Future<void> _loadStats() async {
|
Future<void> _loadStats() async {
|
||||||
final stats = await twonlyDB.mediaFilesDao.getStorageStats();
|
final stats = await twonlyDB.mediaFilesDao.getStorageStats();
|
||||||
|
final memoriesUsage = await apiService.getMemoriesUsage();
|
||||||
|
final cloudOnlyCount = await twonlyDB.mediaFilesDao
|
||||||
|
.getCloudOnlyMemoriesCount();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_stats = stats;
|
_stats = stats;
|
||||||
|
_memoriesUsage = memoriesUsage;
|
||||||
|
_cloudOnlyCount = cloudOnlyCount;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _disableBackup() async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => Dialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.lang.settingsStorageDisableBackupTitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
children: formattedText(
|
||||||
|
context,
|
||||||
|
context.lang.settingsStorageDisableBackupBody(
|
||||||
|
_cloudOnlyCount,
|
||||||
|
),
|
||||||
|
textColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.secondaryMiddle,
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(context.lang.galleryCancel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.errorMiddle,
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(context.lang.settingsStorageDisableBackupBtn),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
try {
|
||||||
|
await apiService.disableMemoriesBackup();
|
||||||
|
final allMedias = await (twonlyDB.select(
|
||||||
|
twonlyDB.mediaFiles,
|
||||||
|
)..where((t) => t.stored.equals(true))).get();
|
||||||
|
for (final media in allMedias) {
|
||||||
|
final ms = MediaFileService(media);
|
||||||
|
if (!ms.storedPath.existsSync()) {
|
||||||
|
ms.fullMediaRemoval();
|
||||||
|
await twonlyDB.mediaFilesDao.deleteMediaFile(media.mediaId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await twonlyDB.mediaFilesDao.updateAllMediaFiles(
|
||||||
|
const MediaFilesCompanion(
|
||||||
|
cloudState: Value(CloudState.none),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await UserService.update((u) => u.isCloudBackupEnabled = false);
|
||||||
|
await _loadStats();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
showSnackbar(context, e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final currentPlan = context.watch<PurchasesProvider>().plan;
|
||||||
|
final isFreePlan = currentPlan == SubscriptionPlan.Free;
|
||||||
|
|
||||||
final totalBytes = _stats.entries
|
final totalBytes = _stats.entries
|
||||||
.where((e) => e.key != MediaType.audio)
|
.where((e) => e.key != MediaType.audio)
|
||||||
.fold<int>(0, (a, b) => a + b.value);
|
.fold<int>(0, (a, b) => a + b.value);
|
||||||
|
|
@ -44,8 +162,243 @@ class _ManageStorageViewState extends State<ManageStorageView> {
|
||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
|
if (isFreePlan) ...[
|
||||||
|
Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.outlineVariant.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => context.push(Routes.settingsSubscription),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.lang.settingsStorageNoCloudBackupTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
context.lang.settingsStorageNoCloudBackupCard,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
] else ...[
|
||||||
|
Text(
|
||||||
|
context.lang.memoriesBackupTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (!userService.currentUser.isCloudBackupEnabled) ...[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.lang.settingsStorageNoCloudBackupCard,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
MyButton(
|
||||||
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
|
onPressed: () async {
|
||||||
|
await UserService.update(
|
||||||
|
(u) => u.isCloudBackupEnabled = true,
|
||||||
|
);
|
||||||
|
setState(() {});
|
||||||
|
unawaited(memoriesCloudService.checkUploads());
|
||||||
|
},
|
||||||
|
child: Text(context.lang.enable),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
] else ...[
|
||||||
|
Text(
|
||||||
|
_memoriesUsage != null
|
||||||
|
? '${formatBytes(_memoriesUsage!.currentBytes.toInt())} / ${formatBytes(_memoriesUsage!.maxBytes.toInt())}'
|
||||||
|
: '-',
|
||||||
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Container(
|
||||||
|
height: 24,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (_memoriesUsage == null ||
|
||||||
|
_memoriesUsage!.maxBytes == 0) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final maxWidth = constraints.maxWidth;
|
||||||
|
final current = _memoriesUsage!.currentBytes.toDouble();
|
||||||
|
final max = _memoriesUsage!.maxBytes.toDouble();
|
||||||
|
final usageWidth =
|
||||||
|
((current / max).clamp(0.0, 1.0)) * maxWidth;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
if (usageWidth > 0)
|
||||||
|
Container(
|
||||||
|
width: usageWidth,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
StreamBuilder<MemoriesBackupProgress?>(
|
||||||
|
initialData: memoriesCloudService.currentProgress,
|
||||||
|
stream: memoriesCloudService.progressStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final progress = snapshot.data;
|
||||||
|
final isSyncing =
|
||||||
|
progress != null && progress.totalPending > 0;
|
||||||
|
final percent = isSyncing
|
||||||
|
? ((progress.currentUploaded / progress.totalPending) +
|
||||||
|
(progress.currentUploadProgress /
|
||||||
|
progress.totalPending))
|
||||||
|
.clamp(0.0, 1.0)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (isSyncing) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Syncing: ${progress.currentUploaded} / ${progress.totalPending} files (${(percent * 100).toStringAsFixed(1)}%)',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: percent,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Align(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.primaryDense,
|
||||||
|
onPressed: isSyncing
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
final memories = await twonlyDB.mediaFilesDao
|
||||||
|
.getMemoriesToBackup();
|
||||||
|
if (memories.isEmpty) {
|
||||||
|
if (context.mounted) {
|
||||||
|
showSnackbar(
|
||||||
|
context,
|
||||||
|
context
|
||||||
|
.lang
|
||||||
|
.settingsStorageSyncUpToDate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
unawaited(
|
||||||
|
memoriesCloudService.checkUploads(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.sync, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(context.lang.settingsStorageSyncNow),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Align(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.secondaryDense,
|
||||||
|
onPressed: _disableBackup,
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.cloud_off_outlined, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
context.lang.settingsStorageDisableBackupAction,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
context.lang.settingsStorageUsed,
|
isFreePlan
|
||||||
|
? context.lang.settingsStorageUsed
|
||||||
|
: context.lang.settingsStorageLocal,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
@ -104,6 +457,14 @@ class _ManageStorageViewState extends State<ManageStorageView> {
|
||||||
size: formatBytes(gifBytes),
|
size: formatBytes(gifBytes),
|
||||||
color: Colors.orange,
|
color: Colors.orange,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Align(
|
||||||
|
child: MyButton(
|
||||||
|
variant: MyButtonVariant.primaryMiddle,
|
||||||
|
onPressed: () => context.navPush(const StorageContentsView()),
|
||||||
|
child: Text(context.lang.settingsStorageContents),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,412 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_blurhash/flutter_blurhash.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
|
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/components/delete_memories_dialog.comp.dart';
|
||||||
|
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||||
|
|
||||||
|
enum StorageSortOption {
|
||||||
|
size,
|
||||||
|
newest,
|
||||||
|
oldest,
|
||||||
|
}
|
||||||
|
|
||||||
|
class StorageContentsView extends StatefulWidget {
|
||||||
|
const StorageContentsView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StorageContentsView> createState() => _StorageContentsViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StorageContentsViewState extends State<StorageContentsView> {
|
||||||
|
StorageSortOption _sortOption = StorageSortOption.size;
|
||||||
|
final Set<String> _selectedMediaIds = {};
|
||||||
|
late final Stream<List<MediaFile>> _mediaFilesStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_mediaFilesStream = twonlyDB.mediaFilesDao.watchAllStoredMediaFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSelection(String mediaId) {
|
||||||
|
setState(() {
|
||||||
|
if (_selectedMediaIds.contains(mediaId)) {
|
||||||
|
_selectedMediaIds.remove(mediaId);
|
||||||
|
} else {
|
||||||
|
_selectedMediaIds.add(mediaId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return StreamBuilder<List<MediaFile>>(
|
||||||
|
stream: _mediaFilesStream,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final allFiles = snapshot.data ?? [];
|
||||||
|
final storedFiles = allFiles.where((file) {
|
||||||
|
final ms = MediaFileService(file);
|
||||||
|
return file.stored ||
|
||||||
|
(ms.storedPath.existsSync() && ms.storedPath.lengthSync() > 0);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Sort the files
|
||||||
|
switch (_sortOption) {
|
||||||
|
case StorageSortOption.size:
|
||||||
|
storedFiles.sort(
|
||||||
|
(a, b) => (b.sizeInBytes ?? 0).compareTo(a.sizeInBytes ?? 0),
|
||||||
|
);
|
||||||
|
case StorageSortOption.newest:
|
||||||
|
storedFiles.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||||
|
case StorageSortOption.oldest:
|
||||||
|
storedFiles.sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
final isSelecting = _selectedMediaIds.isNotEmpty;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
leading: isSelecting
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () {
|
||||||
|
setState(_selectedMediaIds.clear);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
title: isSelecting
|
||||||
|
? Text(
|
||||||
|
context.lang.memoriesSelectedCount(
|
||||||
|
_selectedMediaIds.length,
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
)
|
||||||
|
: Text(context.lang.settingsStorageContents),
|
||||||
|
actions: [
|
||||||
|
if (isSelecting)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: () => _batchDelete(context, storedFiles),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: snapshot.connectionState == ConnectionState.waiting
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: snapshot.hasError
|
||||||
|
? Center(child: Text('Error: ${snapshot.error}'))
|
||||||
|
: storedFiles.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
context.lang.settingsStorageNoContents,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodyLarge?.copyWith(color: Colors.grey),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildChip(
|
||||||
|
StorageSortOption.size,
|
||||||
|
context.lang.settingsStorageSortStorage,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildChip(
|
||||||
|
StorageSortOption.newest,
|
||||||
|
context.lang.settingsStorageSortNewest,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildChip(
|
||||||
|
StorageSortOption.oldest,
|
||||||
|
context.lang.settingsStorageSortOldest,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: storedFiles.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = storedFiles[index];
|
||||||
|
return _buildListItem(context, file);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChip(StorageSortOption option, String label) {
|
||||||
|
final isSelected = _sortOption == option;
|
||||||
|
return ChoiceChip(
|
||||||
|
label: Text(label),
|
||||||
|
selected: isSelected,
|
||||||
|
onSelected: (selected) {
|
||||||
|
if (selected) {
|
||||||
|
setState(() {
|
||||||
|
_sortOption = option;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildListItem(BuildContext context, MediaFile file) {
|
||||||
|
final sizeStr = formatBytes(file.sizeInBytes ?? 0);
|
||||||
|
final dateStr = DateFormat.yMMMd(
|
||||||
|
Localizations.localeOf(context).toString(),
|
||||||
|
).format(file.createdAt);
|
||||||
|
|
||||||
|
final isSelected = _selectedMediaIds.contains(file.mediaId);
|
||||||
|
final isSelecting = _selectedMediaIds.isNotEmpty;
|
||||||
|
|
||||||
|
final ms = MediaFileService(file);
|
||||||
|
final isLocal =
|
||||||
|
ms.storedPath.existsSync() && ms.storedPath.lengthSync() > 0;
|
||||||
|
|
||||||
|
final IconData statusIcon;
|
||||||
|
final String statusText;
|
||||||
|
|
||||||
|
switch (file.cloudState) {
|
||||||
|
case CloudState.none:
|
||||||
|
statusIcon = Icons.cloud_off_outlined;
|
||||||
|
statusText = context.lang.settingsStorageLocalOnly;
|
||||||
|
case CloudState.pending:
|
||||||
|
statusIcon = Icons.cloud_upload_outlined;
|
||||||
|
statusText = context.lang.settingsStorageLocalOnly;
|
||||||
|
case CloudState.uploaded:
|
||||||
|
if (isLocal) {
|
||||||
|
statusIcon = Icons.cloud_done_outlined;
|
||||||
|
statusText = context.lang.settingsStorageLocalAndCloud;
|
||||||
|
} else {
|
||||||
|
statusIcon = Icons.cloud_outlined;
|
||||||
|
statusText = context.lang.settingsStorageCloudOnly;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
minLeadingWidth: 0,
|
||||||
|
horizontalTitleGap: 8,
|
||||||
|
selected: isSelected,
|
||||||
|
onTap: () => _toggleSelection(file.mediaId),
|
||||||
|
onLongPress: () => _toggleSelection(file.mediaId),
|
||||||
|
leading: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (isSelecting) ...[
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: Checkbox(
|
||||||
|
value: isSelected,
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
onChanged: (val) => _toggleSelection(file.mediaId),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
_buildThumbnail(context, file),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
title: Text('$sizeStr • $dateStr'),
|
||||||
|
subtitle: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
file.type.name.toUpperCase(),
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(statusIcon, size: 14, color: Colors.grey),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
statusText,
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
trailing: isSelecting
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, color: Colors.redAccent),
|
||||||
|
onPressed: () => _deleteMemory(context, file),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThumbnail(BuildContext context, MediaFile file) {
|
||||||
|
final ms = MediaFileService(file);
|
||||||
|
final isVideo = file.type == MediaType.video;
|
||||||
|
|
||||||
|
final imgFile =
|
||||||
|
ms.thumbnailPath.existsSync() && ms.thumbnailPath.lengthSync() > 0
|
||||||
|
? ms.thumbnailPath
|
||||||
|
: ((file.type == MediaType.image || file.type == MediaType.gif) &&
|
||||||
|
ms.storedPath.existsSync() &&
|
||||||
|
ms.storedPath.lengthSync() > 0)
|
||||||
|
? ms.storedPath
|
||||||
|
: (ms.tempPath.existsSync() && ms.tempPath.lengthSync() > 0 && !isVideo)
|
||||||
|
? ms.tempPath
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
color: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: imgFile != null
|
||||||
|
? Image.file(
|
||||||
|
imgFile,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (context, error, stackTrace) => const Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
size: 24,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: file.blurhash != null
|
||||||
|
? BlurHash(
|
||||||
|
hash: file.blurhash!,
|
||||||
|
optimizationMode: BlurHashOptimizationMode.approximation,
|
||||||
|
)
|
||||||
|
: const Icon(
|
||||||
|
Icons.image_outlined,
|
||||||
|
size: 24,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isVideo)
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteMemory(BuildContext context, MediaFile file) async {
|
||||||
|
final hasCloudBackup = file.cloudState == CloudState.uploaded;
|
||||||
|
|
||||||
|
final deleteCompletely = await showDeleteMemoriesDialog(
|
||||||
|
context: context,
|
||||||
|
count: 1,
|
||||||
|
hasCloudBackup: hasCloudBackup,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleteCompletely == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (deleteCompletely) {
|
||||||
|
await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId);
|
||||||
|
unawaited(apiService.deleteMemory(file.mediaId));
|
||||||
|
MediaFileService(file).fullMediaRemoval();
|
||||||
|
} else {
|
||||||
|
MediaFileService(file).storedPath.deleteSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
showSnackbar(
|
||||||
|
context,
|
||||||
|
context.lang.memoriesDeleteSnackbarSuccess(1),
|
||||||
|
level: SnackbarLevel.success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
showSnackbar(
|
||||||
|
context,
|
||||||
|
e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _batchDelete(
|
||||||
|
BuildContext context,
|
||||||
|
List<MediaFile> allFiles,
|
||||||
|
) async {
|
||||||
|
final selectedCount = _selectedMediaIds.length;
|
||||||
|
if (selectedCount == 0) return;
|
||||||
|
|
||||||
|
final selectedFiles = allFiles
|
||||||
|
.where((file) => _selectedMediaIds.contains(file.mediaId))
|
||||||
|
.toList();
|
||||||
|
final hasAnyCloudBackup = selectedFiles.any(
|
||||||
|
(file) => file.cloudState == CloudState.uploaded,
|
||||||
|
);
|
||||||
|
|
||||||
|
final deleteCompletely = await showDeleteMemoriesDialog(
|
||||||
|
context: context,
|
||||||
|
count: selectedCount,
|
||||||
|
hasCloudBackup: hasAnyCloudBackup,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deleteCompletely == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (final file in selectedFiles) {
|
||||||
|
if (deleteCompletely) {
|
||||||
|
await twonlyDB.mediaFilesDao.deleteMediaFile(file.mediaId);
|
||||||
|
unawaited(apiService.deleteMemory(file.mediaId));
|
||||||
|
MediaFileService(file).fullMediaRemoval();
|
||||||
|
} else {
|
||||||
|
MediaFileService(file).storedPath.deleteSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(_selectedMediaIds.clear);
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
showSnackbar(
|
||||||
|
context,
|
||||||
|
context.lang.memoriesDeleteSnackbarSuccess(selectedCount),
|
||||||
|
level: SnackbarLevel.success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
showSnackbar(
|
||||||
|
context,
|
||||||
|
e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -221,6 +221,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
||||||
isFavorite: false,
|
isFavorite: false,
|
||||||
hasCropAnalyzed: false,
|
hasCropAnalyzed: false,
|
||||||
hasThumbnail: false,
|
hasThumbnail: false,
|
||||||
|
cloudState: CloudState.none,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
);
|
);
|
||||||
final mediaService = MediaFileService(mediaFile);
|
final mediaService = MediaFileService(mediaFile);
|
||||||
|
|
|
||||||
133
lib/src/visual/views/settings/help/news.view.dart
Normal file
133
lib/src/visual/views/settings/help/news.view.dart
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:twonly/locator.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
import 'package:twonly/src/visual/elements/reactive_tap_feedback.element.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class NewsView extends StatefulWidget {
|
||||||
|
const NewsView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NewsView> createState() => _NewsViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NewsViewState extends State<NewsView> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Mark all as read when entering the page
|
||||||
|
newsService.markAllAsRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final entries = newsService.entries;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(context.lang.settingsHelpNews),
|
||||||
|
),
|
||||||
|
body: entries.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'No news articles found.',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: entries.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final entry = entries[index];
|
||||||
|
final isDark = isDarkMode(context);
|
||||||
|
|
||||||
|
return ReactiveTapFeedback(
|
||||||
|
onTap: () => launchUrl(
|
||||||
|
Uri.parse(entry.link),
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
),
|
||||||
|
child: Card(
|
||||||
|
color: isDark ? Colors.grey[800] : Colors.grey[200],
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
if (entry.imageUrl.isNotEmpty)
|
||||||
|
CachedNetworkImage(
|
||||||
|
imageUrl: entry.imageUrl,
|
||||||
|
height: 180,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) => Container(
|
||||||
|
height: 180,
|
||||||
|
color: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
errorWidget: (context, url, error) => Container(
|
||||||
|
height: 180,
|
||||||
|
color: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.broken_image,
|
||||||
|
size: 50,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (entry.pubDate != null) ...[
|
||||||
|
Text(
|
||||||
|
DateFormat.yMMMMd(
|
||||||
|
Localizations.localeOf(context).toString(),
|
||||||
|
).format(entry.pubDate!),
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
entry.title,
|
||||||
|
style: Theme.of(context).textTheme.titleLarge
|
||||||
|
?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
entry.description,
|
||||||
|
maxLines: 3,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium
|
||||||
|
?.copyWith(
|
||||||
|
color: context.color.onSurface.withValues(
|
||||||
|
alpha: 0.8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -436,7 +436,10 @@ class UserDiscoverySetupComp extends StatelessWidget {
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const AvatarIcon(fontSize: 14),
|
const AvatarIcon(
|
||||||
|
fontSize: 14,
|
||||||
|
myAvatar: true,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
|
||||||
112
memories.md
Normal file
112
memories.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Memories API Documentation
|
||||||
|
|
||||||
|
The **Memories** feature allows users to back up their media to the server in an end-to-end encrypted format. The client-server communication happens primarily via the existing WebSocket connection using Protocol Buffers (`client_to_server.proto` and `server_to_client.proto`), while the actual binary file uploads/downloads use Amazon S3 presigned URLs via HTTP PUT/GET.
|
||||||
|
|
||||||
|
## 1. Checking Storage Quota
|
||||||
|
Before allowing a user to upload a memory, the client should query the user's current storage usage to ensure they haven't exceeded their limit.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```proto
|
||||||
|
// client_to_server.proto
|
||||||
|
message GetMemoriesUsage {}
|
||||||
|
```
|
||||||
|
**Response:**
|
||||||
|
```proto
|
||||||
|
// server_to_client.proto
|
||||||
|
message MemoriesUsage {
|
||||||
|
int64 max_bytes = 1;
|
||||||
|
int64 current_bytes = 2;
|
||||||
|
int64 count = 3;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If `current_bytes` + the new file size > `max_bytes`, the client should block the upload and optionally prompt the user to upgrade their plan or free up space.
|
||||||
|
|
||||||
|
## 2. Uploading a Memory
|
||||||
|
Uploading a memory is a three-step process: requesting presigned URLs, performing the actual HTTP uploads, and finally confirming the upload.
|
||||||
|
|
||||||
|
### Step 2.1: Request Upload URLs
|
||||||
|
The client requests permission to upload a new memory item, declaring its size and original capture date.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```proto
|
||||||
|
// client_to_server.proto
|
||||||
|
message RequestMemoriesUpload {
|
||||||
|
int64 size = 1; // Size of the FULL media file in bytes
|
||||||
|
int64 original_date = 2; // Unix timestamp (in milliseconds/seconds depending on your app's standard) of when the media was originally created
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```proto
|
||||||
|
// server_to_client.proto
|
||||||
|
message MemoriesUploadUrls {
|
||||||
|
string media_id = 1; // Unique identifier generated by the server for this memory
|
||||||
|
string thumbnail_upload_url = 2; // S3 presigned URL for uploading the encrypted thumbnail
|
||||||
|
string full_upload_url = 3; // S3 presigned URL for uploading the encrypted full media
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2.2: Perform HTTP Uploads
|
||||||
|
Using the URLs returned in Step 2.1, the client encrypts the thumbnail and the full media locally, and performs an HTTP `PUT` request to the respective URLs.
|
||||||
|
|
||||||
|
*Note: The upload URLs are usually valid only for a short time (e.g. 15-60 minutes). Ensure you upload the data promptly.*
|
||||||
|
|
||||||
|
### Step 2.3: Confirm the Upload
|
||||||
|
After both the thumbnail and the full media are successfully uploaded via HTTP, the client **must** notify the server so it can mark the memory as fully uploaded and available.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```proto
|
||||||
|
// client_to_server.proto
|
||||||
|
message ConfirmMemoriesUpload {
|
||||||
|
string media_id = 1; // The media_id obtained in Step 2.1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*(The server will respond with an empty `Ok::None(true)` acknowledgment upon success).*
|
||||||
|
|
||||||
|
|
||||||
|
## 3. Retrieving Memories (Pagination)
|
||||||
|
To display a gallery or timeline of backed-up memories, the client requests a paginated list of media items. The endpoint returns a descending list (newest first) based on the original date.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```proto
|
||||||
|
// client_to_server.proto
|
||||||
|
message GetMemoriesList {
|
||||||
|
int64 offset_date = 1; // Unix timestamp to fetch memories older than this date (for pagination). Use MAX_INT64 for the first page.
|
||||||
|
int64 limit = 2; // Maximum number of items to return in this batch (e.g. 50)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```proto
|
||||||
|
// server_to_client.proto
|
||||||
|
message MemoriesList {
|
||||||
|
repeated MediaItem items = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MediaItem {
|
||||||
|
string media_id = 1;
|
||||||
|
int64 original_date = 2;
|
||||||
|
string thumbnail_download_url = 3; // S3 presigned URL to download the encrypted thumbnail
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The client can immediately use the `thumbnail_download_url` to fetch and decrypt the thumbnail for the gallery view.
|
||||||
|
|
||||||
|
## 4. Downloading Full Media
|
||||||
|
When a user taps on a memory to view it in full screen, the client requests a temporary download URL for the full-resolution file.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```proto
|
||||||
|
// client_to_server.proto
|
||||||
|
message GetMemoriesUrl {
|
||||||
|
string media_id = 1;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```proto
|
||||||
|
// server_to_client.proto
|
||||||
|
message MemoriesUrl {
|
||||||
|
string full_download_url = 1; // S3 presigned URL to download the encrypted full media
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The client can then perform an HTTP `GET` request to `full_download_url`, decrypt the payload locally, and display the full-resolution image or video.
|
||||||
170
proto.md
Normal file
170
proto.md
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
---
|
||||||
|
title: "Break my Passwordless Recovery Protocol and get a 50€ Bug-Bounty"
|
||||||
|
date: 2026-06-23
|
||||||
|
description: I designed a passwordless recovery protocol using trusted friends and a server-side second factor for my privacy-focused messaging app twonly. To ensure its security before release, I am launching a 50€ bug bounty challenge for anyone who can find a vulnerability in the protocol.
|
||||||
|
keywords: twonly, Bug-Bounty, Protocol-Design
|
||||||
|
---
|
||||||
|
# Break my Passwordless Recovery Protocol and get a 50€ Bug-Bounty
|
||||||
|
|
||||||
|
A year ago I started building an Open-Source Snapchat alternative called [twonly](https://twonly.eu), as I liked the basic features of Snapchat, but wanted my images and text messages to not be [scanned](https://heise.de/-11246086), viewed by their [employees](https://www.vice.com/en/article/snapchat-employees-abused-data-access-spy-on-users-snaplion/), or potentially by attackers or [a government](https://www.bbc.com/news/world-europe-68099669).
|
||||||
|
|
||||||
|
Directly from the start, users frequently lost access to their account after installing the app on a new device or losing their old phone. So a recovery mechanism was needed. Signal and WhatsApp use, beside their ([unencrypted (WhatsApp)](https://wire.com/en/blog/when-opt-in-security-fails-whatsapp-backup-example)) backup, the user's phone number to identify the user and allow them to reset their private key.
|
||||||
|
|
||||||
|
But I do not want to use phone numbers, as this would undermine the [users' privacy](https://twonly.eu/en/blog/2026-mutual-friends.html#the-problem-with-phone-numbers). Also, allowing users to recover their accounts using solely a phone number moves the Root of Trust from the private key to the phone number and with that to the server, which claims to have verified it. The server can then notify contacts about the user's new public key, which the clients accept. Yes, the clients show "Your safety numbers have changed", but this is ignored by almost all users (from my own experience).
|
||||||
|
|
||||||
|
As a starting point I decided to use a similar approach to [Threema's Safe](https://threema.com/en/faq/threema-safe-security), as the protocol was already designed and audited by experts. But this system requires the user to remember their password to protect the uploaded private key. And believe it or not, users frequently forget their password, again requiring them to recreate their account, losing all their friends, rejoining existing groups, and, in the case of an E2EE encrypted image backup which I am planning to implement, losing access to their precious images. To fix this I started designing a passwordless recovery protocol involving Trusted Friends and the server as a second factor.
|
||||||
|
|
||||||
|
When involving friends to store a share of the user's secret key, it must be ensured that these are actually their friends' accounts and not an attacker impersonating their actual friends or a potentially compromised server performing an active MITM attack. For this, I already [rolled](https://twonly.eu/en/blog/2026-mutual-friends.html) out as part of [my master's thesis](https://mastodon.social/@twonly/115966775370449880) an improved Authentication Ceremony which users can more easily understand and which is partially automated via a Web of Trust approach. Users then can only select such verified friends.
|
||||||
|
|
||||||
|
But as I want this protocol to be secure, it should ideally be verified by external security professionals. However, as a student, I cannot afford a professional audit. So before releasing it to the public, I am starting a 50€ Bug Bounty challenge with this blog post. The deal: **You find a flaw in my protocol, you receive 50€.** And yes, this may not sound like much, but hey, I am a student and have to pay this out of my own pocket. At the end of this post, you will find the rules and the security goals for the bug bounty.
|
||||||
|
|
||||||
|
## The Passwordless Recovery Protocol
|
||||||
|
|
||||||
|
<p style="background: #121314; padding: 15px 20px; margin-bottom: 20px;">
|
||||||
|
<strong>Update (July 19, 2026):</strong> I received feedback from a cryptography expert, who pointed out that my original trick of withholding the nonce from the server might work in practice, but it prevents a formal security proof under standard AEAD models. So I updated the protocol: Instead of the nonce, the encryption key for the serverKey is now withheld, distributed to the trusted friends, and only sent to the server during the recovery.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
To enable the passwordless recovery, a user first has to select at least **T + 2** verified trusted friends (N), where T is the threshold for [Shamir's Secret Sharing](https://en.wikipedia.org/wiki/Shamir%27s_secret_sharing). The + 2 is there so that even if a couple of friends lose their phone or become unreachable, there are still enough shares left to recover. Then they can decide to enable a second factor, either a PIN or an email. From there, the user has finished their task. In the background the user's secret key is then split into multiple parts and then distributed to the selected friends. In case the second factor was enabled, these shares are further encrypted using a server key. Here is the full setup flow:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant S as Server
|
||||||
|
participant F as Friends
|
||||||
|
|
||||||
|
U->>U: RecoveryData = userId + secret key
|
||||||
|
|
||||||
|
alt Second Factor: Email
|
||||||
|
U->>U: Generate random<br/>serverKey (32 bytes)<br/>serverKeyProtection (32 bytes)<br/>Nonce (24 bytes)
|
||||||
|
U->>U: emailKey = HKDF(ikm=serverKeyProtection, salt=email)
|
||||||
|
U->>U: protectedServerKey, MAC = XChaCha20-Poly1305.encrypt(serverKey, emailKey, Nonce)
|
||||||
|
U->>S: protectedServerKey + MAC + Nonce
|
||||||
|
U->>U: RecoveryData = XChaCha20-Poly1305.encrypt(RecoveryData, serverKey)
|
||||||
|
else Second Factor: PIN
|
||||||
|
U->>U: Generate random<br/>serverKey (32 bytes)<br/>serverKeyProtection (32 bytes)<br/>pinUnlockToken (32 bytes)<br/>Nonce (24 bytes)
|
||||||
|
U->>U: pinKey = HKDF(ikm=serverKeyProtection, salt=pin)
|
||||||
|
U->>U: protectedServerKey, MAC = XChaCha20-Poly1305.encrypt(serverKey, pinKey, Nonce)
|
||||||
|
U->>S: protectedServerKey + MAC + pinUnlockToken + Nonce
|
||||||
|
U->>U: RecoveryData = XChaCha20-Poly1305.encrypt(RecoveryData, serverKey)
|
||||||
|
else No Second Factor
|
||||||
|
U->>U: increase threshold T from >=2 to >=4
|
||||||
|
end
|
||||||
|
|
||||||
|
U->>U: Bundle SharedSecretData:<br/>RecoveryData +<br/>(optional: serverKeyProtection + pinUnlockToken)
|
||||||
|
|
||||||
|
U->>U: Shamir split SharedSecretData<br/>into N shares (threshold T)
|
||||||
|
|
||||||
|
U->>F: Send each share via<br/>E2E encrypted message
|
||||||
|
F->>F: Store share locally
|
||||||
|
```
|
||||||
|
|
||||||
|
Because the server does not receive the `serverKeyProtection` (32 bytes), the server is unable to brute-force the user's email or PIN. But because it has the MAC, it can then during recovery ensure that it received the original user's input.
|
||||||
|
|
||||||
|
|
||||||
|
### Recovery Flow
|
||||||
|
|
||||||
|
When a user now forgets their backup password and has enabled the passwordless recovery, they can ask their friends to send them their shares. For this, the following flow is used to make it as easy as possible for all participants:
|
||||||
|
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as Recovering User
|
||||||
|
participant S as Server
|
||||||
|
participant F as Trusted Friend
|
||||||
|
participant APP as Friend's App
|
||||||
|
|
||||||
|
|
||||||
|
R->>R: generates random<br/>notificationID (UUID)<br/>downloadAuthToken (32 bytes)<br/>notificationKey (32 bytes)
|
||||||
|
|
||||||
|
R->>R: Read out pushToken
|
||||||
|
|
||||||
|
R->>S: notificationID, downloadAuthToken,<br/>pushToken
|
||||||
|
|
||||||
|
R->>R: Creates link (behind fragment):<br/>notificationID, notificationKey
|
||||||
|
|
||||||
|
R->>F: Shows QR code or shares link
|
||||||
|
|
||||||
|
F->>APP: Opens recovery link / scans QR
|
||||||
|
APP->>APP: Parse notificationId +<br/>notificationKey from URL
|
||||||
|
|
||||||
|
APP->>F: "Select which contact is recovering"
|
||||||
|
F->>APP: Selects contact from list
|
||||||
|
APP->>F: "Are you sure?" phishing-prevention dialog<br/>with a 10s timer
|
||||||
|
F->>APP: Confirms
|
||||||
|
|
||||||
|
APP->>APP: Encrypt stored share + userId<br/>with notificationKey<br/>(XChaCha20-Poly1305)
|
||||||
|
APP->>S: Submit encrypted envelope<br/>(notificationId)
|
||||||
|
S->>R: Push notification:<br/>"A friend submitted a share"
|
||||||
|
```
|
||||||
|
|
||||||
|
A symmetric encryption key was deliberately chosen over an asymmetric keypair: the goal is to protect submitted shares from the server, not from the user. Only the user holding the downloadAuthToken can download the submissions, and only the one holding the notificationKey can decrypt them. This makes the protocol post-quantum safe from the start, without requiring large public keys which cannot be shared via a QR code or a link.
|
||||||
|
|
||||||
|
To reduce potential phishing attacks, the user must manually select which friend they want to help, and then confirm their selection. This confirmation has a 10s timer and a red warning, asking them if they are sure that they have been asked by the selected user via a secure channel (like in-person or via Signal).
|
||||||
|
|
||||||
|
In the last step, the recovering user now can use the collected shares to recover their secret key:
|
||||||
|
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant R as Recovering User
|
||||||
|
participant S as Server
|
||||||
|
participant I as User's inbox
|
||||||
|
|
||||||
|
R->>S: Download encrypted shares<br/>(authenticated via downloadAuthToken)
|
||||||
|
R->>R: Decrypt userId + shares<br/>with notificationKey
|
||||||
|
R->>R: Feed T shares into<br/>Shamir reconstruction
|
||||||
|
R->>R: Obtain SharedSecretData
|
||||||
|
|
||||||
|
alt Email second factor
|
||||||
|
R->>R: Inputs email
|
||||||
|
R->>S: Send userId, SharedSecretData.serverKeyProtection, email
|
||||||
|
S->>S: emailKey = HKDF(ikm=serverKeyProtection, salt=email)
|
||||||
|
S->>S: serverKey = XChaCha20-Poly1305.decrypt(protectedServerKey, emailKey, stored Nonce, stored MAC)
|
||||||
|
S->>I: Send serverKey
|
||||||
|
I->>R: Copy serverKey
|
||||||
|
R->>R: Decrypt RecoveryData<br/>with serverKey
|
||||||
|
else PIN second factor
|
||||||
|
R->>R: pinKey = HKDF(ikm=SharedSecretData.serverKeyProtection,<br/>salt=pin)
|
||||||
|
R->>S: Send userId, pinKey + SharedSecretData.pinUnlockToken
|
||||||
|
S->>S: Check pinUnlockToken
|
||||||
|
S->>S: serverKey = XChaCha20-Poly1305.decrypt(protectedServerKey, pinKey, stored Nonce, stored MAC)<br/>(~10 tries max)
|
||||||
|
S->>R: Return serverKey
|
||||||
|
R->>R: Decrypt RecoveryData<br/>with serverKey
|
||||||
|
else No second factor
|
||||||
|
R->>R: RecoveryData is directly<br/>in SharedSecretData
|
||||||
|
end
|
||||||
|
|
||||||
|
R->>R: Restore userId + secret key
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will automatically delete the `protectedServerKey` when more than 10 tries for the PIN were performed. The reason for the `pinUnlockToken` is to prevent denial-of-service: without it, anyone could just spam the recovery endpoint with wrong keys and trigger the 10-attempt lockout, permanently destroying the user's `protectedServerKey`. Since only the trusted friends hold the Shamir shares containing the `pinUnlockToken`, only they (in case they collude) or the legitimate recovering user can actually initiate a recovery attempt.
|
||||||
|
|
||||||
|
As this protocol is designed to only recover small amounts of data (the secret key), this is combined with an encrypted backup in the cloud, which is protected by the user's secret key that can then be downloaded by the user, restoring their contacts, messages and images.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Below is a list of my security goals; compromise at least one and you qualify for the 50€ Bug-Bounty. If you find another security issue or encounter any other problem, I'm all ears.
|
||||||
|
- You can ask your agent, but you must disclose that, as I am curious what you prompted :).
|
||||||
|
- First come, first served. The bug-bounty is limited to one payout. Sorry, I am a student :/
|
||||||
|
- I reserve the right to decide for myself if the flaw is worth the bug bounty payout.
|
||||||
|
- The Bug-Bounty is only paid out in Cash, Bank-Transfer, or WERO.
|
||||||
|
|
||||||
|
### Security Goals
|
||||||
|
|
||||||
|
- The server should not be able to link a user and an email until the user recovers their account.
|
||||||
|
- The server should not be able to get access to the plaintext PIN.
|
||||||
|
- The server should never be able to get access to the user's secret key.
|
||||||
|
- A single trusted friend should never be able to recover the user's secret key on their own.
|
||||||
|
- The trusted friends should, in case the second factor is enabled, not be able to collude and recover the user's secret key.
|
||||||
|
|
||||||
|
### Assumptions
|
||||||
|
|
||||||
|
- The server does not collude with the trusted friends.
|
||||||
|
- The trusted friends are unable to guess the user's PIN with under 10 attempts.
|
||||||
|
- The trusted friends are unable to access the user's inbox.
|
||||||
|
- In case of no second factor, the trusted friends (at least 4) do not collude.
|
||||||
|
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
If you have found something, please send either an [email](mailto:security@tsmr.eu) or find me on Signal: tobi.02.
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue