Merge pull request #423 from twonlyapp/dev

Dev
This commit is contained in:
Tobi 2026-07-21 20:46:22 +02:00 committed by GitHub
commit fb7acc9f71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
117 changed files with 22745 additions and 2470 deletions

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -215,4 +215,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: 245e6d5f26c858edb6b99a7d972cc93ead4d55cf PODFILE CHECKSUM: 245e6d5f26c858edb6b99a7d972cc93ead4d55cf
COCOAPODS: 1.16.2 COCOAPODS: 1.17.0

View file

@ -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();
}
} }

View file

@ -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();

View file

@ -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_,
); );
}, },

View file

@ -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;
} }

View file

@ -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>();

View file

@ -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());

View file

@ -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 =

View file

@ -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;
}
} }

File diff suppressed because it is too large Load diff

View file

@ -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};
} }

View file

@ -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);
}, },
); );

View file

@ -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,

View file

@ -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,
), ),
); );

View file

@ -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

View file

@ -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.';
} }

View file

@ -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

View file

@ -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);

View file

@ -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,

View file

@ -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.

View file

@ -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

View file

@ -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 = {

View file

@ -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 }

View file

@ -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');

View file

@ -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)
}

View file

@ -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 =

View file

@ -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=');

View file

@ -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);
} }

View file

@ -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==');

View file

@ -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;
} }

View file

@ -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(),

View file

@ -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,

View file

@ -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,

View file

@ -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;
} }

View file

@ -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();

View file

@ -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;
} }

View file

@ -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);

View file

@ -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()
} }

View file

@ -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();

View file

@ -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;
}

View 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();

View file

@ -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(

View 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('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&#39;', "'")
.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;
}
}
}

View file

@ -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);
}
}
} }

View file

@ -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

View 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),
),
),
],
),
],
],
),
),
);
}
}

View file

@ -98,6 +98,7 @@ class _FlameCounterWidgetState extends State<FlameCounterWidget> {
height: 11, height: 11,
child: EmojiAnimationComp( child: EmojiAnimationComp(
emoji: '', emoji: '',
repeat: false,
), ),
), ),
], ],

View file

@ -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

View file

@ -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

View file

@ -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;

View file

@ -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),
), ),
], ],

View file

@ -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,

View file

@ -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

View file

@ -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 -

View file

@ -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');
});
} }
}; };
} }

View file

@ -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,
), ),
], ),
), ],
), ),
);
},
),
); );
} }
} }

View file

@ -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),
);
},
);
}
}

View file

@ -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 {

View file

@ -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

View file

@ -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),
),
);
},
);
},
);
}
}

View file

@ -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

View file

@ -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),
),
],
), ),
), ),
); );

View file

@ -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,
),
),
),
), ),
), ),
); );

View file

@ -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;
}
}

View file

@ -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),
),
],
);
}
}

View file

@ -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();
} }
} }

View file

@ -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();
} }

View file

@ -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,
),
),
],
),
],
),
),
],
),
),
),
);
},
);
}
}

View file

@ -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),
],
),
);
},
),
], ],
), ),
), ),

View file

@ -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),
],
),
),
],
);
}
}

View file

@ -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,
),
],
),
),
);
}
}

View file

@ -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),
),
),
],
), ),
); );
} }

View file

@ -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);
},
),
),
);
}
}

View file

@ -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),
);
},
),
], ],
), ),
); );

View file

@ -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);
});
}
}
}
}
} }

View file

@ -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),
], ],

View file

@ -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,

View file

@ -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(

View file

@ -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),
), ),
), ),
], ],

View file

@ -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,
),
),
],
),
),
],
),
),
],
),
),
),
),
);
},
);
}
}

View file

@ -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,
), ),
]; ];

View file

@ -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(

View file

@ -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,
),
],
],
);
},
),
],
),
); );
} }

View file

@ -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),

View file

@ -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),
),
), ),
), ),
); );

View file

@ -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,

View file

@ -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),
),
),
], ],
), ),
); );

View file

@ -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(),
);
}
}
}
}

View file

@ -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);

View 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,
),
),
),
],
),
),
],
),
),
);
},
),
);
}
}

View file

@ -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
View 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
View 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