78 Commits

Author SHA1 Message Date
0c42c117a0 chore(all): Bump flutter_secure_storage 2023-05-25 22:41:15 +02:00
d795cb717e feat(i18n): Translate missing profile strings 2023-05-25 21:41:34 +02:00
1d5d1fdf86 fix(ui): Fix keyboard dodging too much in certain situations 2023-05-25 21:34:07 +02:00
d795c34dab feat(service): Do not request storage permission 2023-05-25 14:43:37 +02:00
b38f5c139f chore(all): Bump moxxmpp 2023-05-25 12:47:34 +02:00
b623f32fbf feat(all): Bump moxxmpp
- Bump moxxmpp to allow queueing stanzas that are sent offline
- Should fix #75.
2023-05-24 22:53:24 +02:00
19fd079436 chore(all): Bump moxxmpp 2023-05-23 16:01:40 +02:00
7d70a96533 fix(ui): Add bottom padding to a sticker pack's name
Also replace the weird list with a GroupedListView.
2023-05-22 14:19:05 +02:00
dce6e34289 fix(ui): Fix padding in the new conversations page
Fixes #276.
2023-05-22 13:51:20 +02:00
881f080916 Merge pull request 'Rework the conversation page' (#275) from chore/conversation-rewrite into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/275
2023-05-21 21:56:41 +00:00
051687535b fix(ui): Fix weird padding in the conversation topbar 2023-05-21 23:56:30 +02:00
0b420933e0 fix(ui): Improve contrast in dark mode 2023-05-21 23:48:42 +02:00
0b3876c3f0 feat(ui): Use the same selection effect for the share selection 2023-05-21 23:40:19 +02:00
9711d45a7a chore(ui): Rename overview_menu.dart -> context_menu.dart 2023-05-21 23:36:31 +02:00
8dcba94de7 feat(ui): Improve the selection of conversation items 2023-05-21 23:34:09 +02:00
226dca8c1a feat(ui): Implement the new typing animation 2023-05-21 13:01:18 +02:00
ad01a7e3e3 feat(ui): Re-add audio messages 2023-05-20 15:56:24 +02:00
adde5a4134 feat(ui): Re-implement the sticker picker 2023-05-19 19:52:22 +02:00
9ae1807225 chore(ui): Clean-up the selection effect 2023-05-19 13:56:44 +02:00
e7f8446c02 feat(ui): Implement a prettier overview animation 2023-05-17 21:27:58 +02:00
7b05bf200c feat(ui): Implement tailor-made keyboard dodging 2023-05-16 23:42:19 +02:00
e992cb309f chore(all): Bump moxxmpp 2023-05-16 13:46:52 +02:00
0f138678ec fix(ui): Fix a sticker as the first message not appearing
Fixes #274.
2023-05-16 12:35:35 +02:00
35658e611a fix(ui): Do not clear the conversation on exit
Fixes #262.

Since we no longer query the BLoC whether we should send a chat state
notification, but instead ask the controller, we can safely remove
the clearing of the `conversation` field.
2023-05-16 12:18:46 +02:00
2a25cd44cf fix(service): Fix invalid hashes being sent with stickers
Fixes #273.

Also fixes:
- Weird (wrong) serialization of the hash maps
- An issue with migrations when passing a const list

NOTE: If you ran Moxxy between the merge of #267 and this commit, you
have to remove Moxxy's data and start anew.
2023-05-16 01:07:40 +02:00
29053df245 chore(ui): Completely rework the BorderlessTopbar
Fixes #249.
2023-05-15 14:39:28 +02:00
78ad02ec80 feat(ui,service): Allow replying with a sticker
Fixes #270.
2023-05-15 12:57:58 +02:00
e3f2ef22a6 fix(ui): Update the conversation list when an upload failed
Fixes #271.
2023-05-15 11:09:03 +02:00
f884e181e3 fix(ui): Sticker quotes now say "Sticker"
Fixes #268.
2023-05-15 00:07:51 +02:00
e69d7ed0a2 feat(ui): Make quote roundings better
Fixes #269.
2023-05-14 23:54:46 +02:00
d65e11a3ea feat(ui): Prepend a "You:" when the last message was sent by us
Fixes #242. Reference #258. Thanks!
2023-05-14 23:06:17 +02:00
294d0ee02c Merge pull request 'Improve the database' (#267) from feat/message-rework into master
Reviewed-on: https://codeberg.org/moxxy/moxxy/pulls/267
2023-05-14 20:51:45 +00:00
6f4abebb32 feat(service): Make adding migrations less of a hassle 2023-05-14 18:07:08 +02:00
5d83796b37 chore(service): Move methods into their respective services 2023-05-14 17:37:02 +02:00
a06c697fe3 fix(service): Remove the 'reactions' column 2023-05-14 16:45:18 +02:00
5de2a8b6af fix(service): Fix migration 2023-05-14 15:07:40 +02:00
7234f67c42 chore(ui): Fix formatting issue 2023-05-14 14:19:41 +02:00
972f5079f9 feat(service): Create indices for common queries 2023-05-14 14:18:56 +02:00
27d4ed1781 feat(ui): Test that padding the reactions list works 2023-05-14 12:26:25 +02:00
5f074ef695 feat(ui): Make the reactions preview slightly transparent 2023-05-14 12:10:44 +02:00
d0f60519fd feat(ui): Fill in the reaction list with actual data 2023-05-14 12:02:38 +02:00
cd7c495cb7 feat(ui): Show 'You' if a reaction came from us 2023-05-14 00:46:54 +02:00
59317d45f9 chore(ui): Move the reaction bubble colors to the constants 2023-05-14 00:18:25 +02:00
7c2c9f978d chore(shared): Fix naming issue in the reaction model 2023-05-14 00:13:51 +02:00
d540f0c2f2 chore(ui): Add documentation 2023-05-14 00:07:59 +02:00
340bbb7ca8 feat(ui): Make reactions look prettier 2023-05-14 00:00:11 +02:00
0aaffd1249 fix(ui,service): Fix linter issues 2023-05-13 22:14:14 +02:00
04be2e8c88 fix(service): Fix removing a reaction 2023-05-13 22:12:05 +02:00
57dbe83901 fix(service): Make adding a reaction work 2023-05-13 22:07:17 +02:00
60c5328eb0 feat(ui,service): Fix linter issues 2023-05-13 21:58:24 +02:00
189d9ca9cd feat(ui): Allow picking a new reaction 2023-05-13 21:48:11 +02:00
5d797b1e66 fix(ui): Make only our own reactions clickable 2023-05-13 20:55:01 +02:00
2f1a40b4d9 feat(ui,service): Allow requesting the reactions on a given message 2023-05-13 20:50:46 +02:00
02c0cd5af0 feat(ui,service): Make reactions work again 2023-05-12 23:35:27 +02:00
f2a70cd137 feat(service): Remove shared media table and attribute 2023-05-11 21:12:36 +02:00
8d88c25f05 feat(ui): Make the shared media list look nicer 2023-05-11 21:06:23 +02:00
c1c5625441 chore(ui,service): Format and lint 2023-05-11 20:36:35 +02:00
462e800907 fix(service): Fix TODOs 2023-05-11 20:35:15 +02:00
faa5ee2c4f feat(ui,service): Implement paged shared media requests 2023-05-11 16:33:55 +02:00
5dad5730ce fix(ui): If an image has no size, decode it full 2023-05-10 13:43:58 +02:00
5017187927 fix(service): Fix file uploading 2023-05-10 13:41:12 +02:00
14e7f72bd3 feat(ui): SHow an error icon if the last message has an error 2023-05-10 12:27:19 +02:00
9ef67f5788 feat(service): Untested work on file sending 2023-05-10 12:26:37 +02:00
79226f6ca8 fix(service): Fix downloading a file again 2023-05-10 00:42:09 +02:00
c8c0239e36 feat(tests): Add tests for getPrefixedSubMap 2023-05-10 00:34:07 +02:00
f1be10bf8c fix(service): Fix database loading 2023-05-10 00:24:39 +02:00
18c3c9d324 feat(all): Use stickers as an extension of SFS 2023-05-09 21:24:20 +02:00
4825fe881d fix(ui): Fix not downloaded files having no background color 2023-05-08 23:57:57 +02:00
081d20fe50 fix(all): Format and lint 2023-05-08 23:57:37 +02:00
c1a66711db feat(service): Verify hash after download 2023-05-08 21:15:54 +02:00
b113e78423 feat(service): Create hash pointers only after integrity checks 2023-05-08 13:27:06 +02:00
470e8aac9c feat(service): Guard against empty SFS source lists 2023-05-08 13:11:03 +02:00
39babfbadd fix(service): Fix wrong type when querying file metadata 2023-05-08 13:08:25 +02:00
86f7e63f65 fix(service): Append the extension to the saved filename 2023-05-08 00:00:54 +02:00
ecd2a71981 feat(ui,service): Port UI and fix first bugs 2023-05-07 22:51:06 +02:00
2ece9e6209 feat(service): Try to bring over the service 2023-05-07 16:56:27 +02:00
9310b9c305 chore(all): Bump moxxmpp 2023-05-07 00:19:09 +02:00
abad9897b8 fix(service): Use database table constants 2023-04-09 13:27:16 +02:00
127 changed files with 7382 additions and 5533 deletions

2
.gitignore vendored
View File

@@ -62,4 +62,4 @@ lib/i18n/*.dart
.android .android
# Build scripts # Build scripts
release/ release-*/

View File

@@ -1,5 +1,5 @@
buildscript { buildscript {
ext.kotlin_version = '1.6.10' ext.kotlin_version = '1.8.21'
repositories { repositories {
google() google()
mavenCentral() mavenCentral()

View File

@@ -194,7 +194,9 @@
}, },
"profile": { "profile": {
"general": { "general": {
"omemo": "Security" "omemo": "Security",
"profile": "Profile",
"media": "Media"
}, },
"conversation": { "conversation": {
"notifications": "Notifications", "notifications": "Notifications",

View File

@@ -194,7 +194,9 @@
}, },
"profile": { "profile": {
"general": { "general": {
"omemo": "Sicherheit" "omemo": "Sicherheit",
"profile": "Profil",
"media": "Medien"
}, },
"conversation": { "conversation": {
"notifications": "Benachrichtigungen", "notifications": "Benachrichtigungen",

View File

@@ -265,14 +265,14 @@ files:
messages: messages:
type: List<Message> type: List<Message>
deserialise: true deserialise: true
# Returned by [GetPagedSharedMediaCommand] # Returned by [GetReactionsForMessageCommand]
- name: PagedSharedMediaResultEvent - name: ReactionsForMessageResult
extends: BackgroundEvent extends: BackgroundEvent
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
media: reactions:
type: List<SharedMedium> type: List<ReactionGroup>
deserialise: true deserialise: true
generate_builder: true generate_builder: true
builder_name: "Event" builder_name: "Event"
@@ -527,9 +527,13 @@ files:
implements: implements:
- JsonImplementation - JsonImplementation
attributes: attributes:
stickerPackId: String sticker:
stickerHashKey: String type: Sticker
deserialise: true
recipient: String recipient: String
quotes:
type: Message?
deserialise: true
- name: FetchStickerPackCommand - name: FetchStickerPackCommand
extends: BackgroundCommand extends: BackgroundCommand
implements: implements:
@@ -566,6 +570,12 @@ files:
conversationJid: String conversationJid: String
olderThan: bool olderThan: bool
timestamp: int? timestamp: int?
- name: GetReactionsForMessageCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
messageId: int
generate_builder: true generate_builder: true
# get${builder_Name}FromJson # get${builder_Name}FromJson
builder_name: "Command" builder_name: "Command"

View File

@@ -59,7 +59,7 @@ import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
import 'package:moxxyv2/ui/pages/settings/settings.dart'; import 'package:moxxyv2/ui/pages/settings/settings.dart';
import 'package:moxxyv2/ui/pages/settings/stickers.dart'; import 'package:moxxyv2/ui/pages/settings/stickers.dart';
import 'package:moxxyv2/ui/pages/share_selection.dart'; import 'package:moxxyv2/ui/pages/share_selection.dart';
import 'package:moxxyv2/ui/pages/sharedmedia.dart'; //import 'package:moxxyv2/ui/pages/sharedmedia.dart';
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart'; import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
import 'package:moxxyv2/ui/pages/sticker_pack.dart'; import 'package:moxxyv2/ui/pages/sticker_pack.dart';
import 'package:moxxyv2/ui/pages/util/qrcode.dart'; import 'package:moxxyv2/ui/pages/util/qrcode.dart';
@@ -275,14 +275,16 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
conversationJid: settings.arguments! as String, conversationJid: settings.arguments! as String,
), ),
); );
case sharedMediaRoute: // case sharedMediaRoute:
return SharedMediaPage.getRoute( // return SharedMediaPage.getRoute(
settings.arguments! as SharedMediaPageArguments, // settings.arguments! as SharedMediaPageArguments,
); // );
case blocklistRoute: case blocklistRoute:
return BlocklistPage.route; return BlocklistPage.route;
case profileRoute: case profileRoute:
return ProfilePage.route; return ProfilePage.getRoute(
settings.arguments! as ProfileArguments,
);
case settingsRoute: case settingsRoute:
return SettingsPage.route; return SettingsPage.route;
case aboutRoute: case aboutRoute:

View File

@@ -93,7 +93,7 @@ class AvatarService {
final am = GetIt.I final am = GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<UserAvatarManager>(userAvatarManager)!; .getManagerById<UserAvatarManager>(userAvatarManager)!;
final idResult = await am.getAvatarId(jid); final idResult = await am.getAvatarId(JID.fromString(jid));
if (idResult.isType<AvatarError>()) { if (idResult.isType<AvatarError>()) {
_log.warning('Failed to get avatar id via XEP-0084 for $jid'); _log.warning('Failed to get avatar id via XEP-0084 for $jid');
return null; return null;
@@ -220,7 +220,7 @@ class AvatarService {
final xss = GetIt.I.get<XmppStateService>(); final xss = GetIt.I.get<XmppStateService>();
final state = await xss.getXmppState(); final state = await xss.getXmppState();
final jid = state.jid!; final jid = state.jid!;
final idResult = await am.getAvatarId(jid); final idResult = await am.getAvatarId(JID.fromString(jid));
if (idResult.isType<AvatarError>()) { if (idResult.isType<AvatarError>()) {
_log.info('Error while getting latest avatar id for own avatar'); _log.info('Error while getting latest avatar id for own avatar');
return; return;

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
@@ -15,6 +16,23 @@ class BlocklistService {
bool? _supported; bool? _supported;
final Logger _log = Logger('BlocklistService'); final Logger _log = Logger('BlocklistService');
Future<void> _removeBlocklistEntry(String jid) async {
await GetIt.I.get<DatabaseService>().database.delete(
blocklistTable,
where: 'jid = ?',
whereArgs: [jid],
);
}
Future<void> _addBlocklistEntry(String jid) async {
await GetIt.I.get<DatabaseService>().database.insert(
blocklistTable,
{
'jid': jid,
},
);
}
void onNewConnection() { void onNewConnection() {
// Invalidate the caches // Invalidate the caches
_blocklist = null; _blocklist = null;
@@ -49,10 +67,9 @@ class BlocklistService {
// Diff the received blocklist with the cache // Diff the received blocklist with the cache
final newItems = List<String>.empty(growable: true); final newItems = List<String>.empty(growable: true);
final removedItems = List<String>.empty(growable: true); final removedItems = List<String>.empty(growable: true);
final db = GetIt.I.get<DatabaseService>();
for (final item in blocklist) { for (final item in blocklist) {
if (!_blocklist!.contains(item)) { if (!_blocklist!.contains(item)) {
await db.addBlocklistEntry(item); await _addBlocklistEntry(item);
_blocklist!.add(item); _blocklist!.add(item);
newItems.add(item); newItems.add(item);
} }
@@ -61,7 +78,7 @@ class BlocklistService {
// Diff the cache with the received blocklist // Diff the cache with the received blocklist
for (final item in _blocklist!) { for (final item in _blocklist!) {
if (!blocklist.contains(item)) { if (!blocklist.contains(item)) {
await db.removeBlocklistEntry(item); await _removeBlocklistEntry(item);
_blocklist!.remove(item); _blocklist!.remove(item);
removedItems.add(item); removedItems.add(item);
} }
@@ -83,7 +100,9 @@ class BlocklistService {
/// Returns the blocklist from the database /// Returns the blocklist from the database
Future<List<String>> getBlocklist() async { Future<List<String>> getBlocklist() async {
if (_blocklist == null) { if (_blocklist == null) {
_blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries(); final blocklistRaw =
await GetIt.I.get<DatabaseService>().database.query(blocklistTable);
_blocklist = blocklistRaw.map((m) => m['jid']! as String).toList();
if (!_requested) { if (!_requested) {
unawaited(_requestBlocklist()); unawaited(_requestBlocklist());
@@ -120,7 +139,7 @@ class BlocklistService {
_blocklist!.add(item); _blocklist!.add(item);
newBlocks.add(item); newBlocks.add(item);
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item); await _addBlocklistEntry(item);
} }
break; break;
case BlockPushType.unblock: case BlockPushType.unblock:
@@ -128,7 +147,7 @@ class BlocklistService {
_blocklist!.removeWhere((i) => i == item); _blocklist!.removeWhere((i) => i == item);
removedBlocks.add(item); removedBlocks.add(item);
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item); await _removeBlocklistEntry(item);
} }
break; break;
} }
@@ -150,7 +169,7 @@ class BlocklistService {
} }
_blocklist!.add(jid); _blocklist!.add(jid);
await GetIt.I.get<DatabaseService>().addBlocklistEntry(jid); await _addBlocklistEntry(jid);
return GetIt.I return GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)! .getManagerById<BlockingManager>(blockingManager)!
@@ -165,7 +184,7 @@ class BlocklistService {
} }
_blocklist!.remove(jid); _blocklist!.remove(jid);
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(jid); await _removeBlocklistEntry(jid);
return GetIt.I return GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)! .getManagerById<BlockingManager>(blockingManager)!
@@ -182,7 +201,8 @@ class BlocklistService {
} }
_blocklist!.clear(); _blocklist!.clear();
await GetIt.I.get<DatabaseService>().removeAllBlocklistEntries(); await GetIt.I.get<DatabaseService>().database.delete(blocklistTable);
return GetIt.I return GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)! .getManagerById<BlockingManager>(blockingManager)!

View File

@@ -5,6 +5,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
@@ -122,7 +123,15 @@ class ContactsService {
Future<Map<String, String>> _getContactIds() async { Future<Map<String, String>> _getContactIds() async {
if (_contactIds != null) return _contactIds!; if (_contactIds != null) return _contactIds!;
_contactIds = await GetIt.I.get<DatabaseService>().getContactIds(); // TODO(Unknown): Can we just .cast<String, String>() here?
_contactIds = Map<String, String>.fromEntries(
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
(item) => MapEntry(
item['jid']! as String,
item['id']! as String,
),
),
);
return _contactIds!; return _contactIds!;
} }
@@ -165,7 +174,6 @@ class ContactsService {
} }
Future<void> scanContacts() async { Future<void> scanContacts() async {
final db = GetIt.I.get<DatabaseService>();
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
final rs = GetIt.I.get<RosterService>(); final rs = GetIt.I.get<RosterService>();
final contacts = await _fetchContactsWithJabber(); final contacts = await _fetchContactsWithJabber();
@@ -183,7 +191,12 @@ class ContactsService {
if (index != -1) continue; if (index != -1) continue;
final jid = knownContactIdsReverse[id]!; final jid = knownContactIdsReverse[id]!;
await db.removeContactId(id); await GetIt.I.get<DatabaseService>().database.delete(
contactsTable,
where: 'id = ?',
whereArgs: [id],
);
_contactIds!.remove(knownContactIdsReverse[id]); _contactIds!.remove(knownContactIdsReverse[id]);
// Remove the avatar file, if it existed // Remove the avatar file, if it existed
@@ -235,7 +248,13 @@ class ContactsService {
for (final contact in contacts) { for (final contact in contacts) {
// Add the ID to the cache and the database if it does not already exist // Add the ID to the cache and the database if it does not already exist
if (!knownContactIds.containsKey(contact.jid)) { if (!knownContactIds.containsKey(contact.jid)) {
await db.addContactId(contact.id, contact.jid); await GetIt.I.get<DatabaseService>().database.insert(
contactsTable,
<String, String>{
'id': contact.id,
'jid': contact.jid,
},
);
_contactIds![contact.jid] = contact.id; _contactIds![contact.jid] = contact.id;
} }

View File

@@ -1,8 +1,12 @@
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/not_specified.dart'; import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
@@ -57,13 +61,48 @@ class ConversationService {
}); });
} }
/// Loads all conversations from the database and adds them to the state and cache.
Future<List<Conversation>> loadConversations() async {
final db = GetIt.I.get<DatabaseService>().database;
final conversationsRaw = await db.query(
conversationsTable,
orderBy: 'lastChangeTimestamp DESC',
);
final tmp = List<Conversation>.empty(growable: true);
for (final c in conversationsRaw) {
final jid = c['jid']! as String;
final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
Message? lastMessage;
if (c['lastMessageId'] != null) {
lastMessage = await GetIt.I.get<MessageService>().getMessageById(
c['lastMessageId']! as int,
jid,
queryReactionPreview: false,
);
}
tmp.add(
Conversation.fromDatabaseJson(
c,
rosterItem != null && !rosterItem.pseudoRosterItem,
rosterItem?.subscription ?? 'none',
lastMessage,
),
);
}
return tmp;
}
/// Wrapper around DatabaseService's loadConversations that adds the loaded /// Wrapper around DatabaseService's loadConversations that adds the loaded
/// to the cache. /// to the cache.
Future<void> _loadConversationsIfNeeded() async { Future<void> _loadConversationsIfNeeded() async {
if (_conversationCache != null) return; if (_conversationCache != null) return;
final conversations = final conversations = await loadConversations();
await GetIt.I.get<DatabaseService>().loadConversations();
_conversationCache = Map<String, Conversation>.fromEntries( _conversationCache = Map<String, Conversation>.fromEntries(
conversations.map((c) => MapEntry(c.jid, c)), conversations.map((c) => MapEntry(c.jid, c)),
); );
@@ -87,7 +126,8 @@ class ConversationService {
_conversationCache![conversation.jid] = conversation; _conversationCache![conversation.jid] = conversation;
} }
/// Wrapper around [DatabaseService]'s [updateConversation] that modifies the cache. /// Updates the conversation with JID [jid] inside the database.
///
/// To prevent issues with the cache, only call from within /// To prevent issues with the cache, only call from within
/// [ConversationService.createOrUpdateConversation]. /// [ConversationService.createOrUpdateConversation].
Future<Conversation> updateConversation( Future<Conversation> updateConversation(
@@ -103,25 +143,58 @@ class ConversationService {
Object? contactId = notSpecified, Object? contactId = notSpecified,
Object? contactAvatarPath = notSpecified, Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified, Object? contactDisplayName = notSpecified,
int? sharedMediaAmount,
}) async { }) async {
final conversation = (await _getConversationByJid(jid))!; final conversation = (await _getConversationByJid(jid))!;
var newConversation =
await GetIt.I.get<DatabaseService>().updateConversation( final c = <String, dynamic>{};
jid,
lastMessage: lastMessage, if (lastMessage != null) {
lastChangeTimestamp: lastChangeTimestamp, c['lastMessageId'] = lastMessage.id;
open: open, }
unreadCounter: unreadCounter, if (lastChangeTimestamp != null) {
avatarUrl: avatarUrl, c['lastChangeTimestamp'] = lastChangeTimestamp;
chatState: conversation.chatState, }
muted: muted, if (open != null) {
encrypted: encrypted, c['open'] = boolToInt(open);
contactId: contactId, }
contactAvatarPath: contactAvatarPath, if (unreadCounter != null) {
contactDisplayName: contactDisplayName, c['unreadCounter'] = unreadCounter;
sharedMediaAmount: sharedMediaAmount, }
); if (avatarUrl != null) {
c['avatarUrl'] = avatarUrl;
}
if (muted != null) {
c['muted'] = boolToInt(muted);
}
if (encrypted != null) {
c['encrypted'] = boolToInt(encrypted);
}
if (contactId != notSpecified) {
c['contactId'] = contactId as String?;
}
if (contactAvatarPath != notSpecified) {
c['contactAvatarPath'] = contactAvatarPath as String?;
}
if (contactDisplayName != notSpecified) {
c['contactDisplayName'] = contactDisplayName as String?;
}
final result =
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
conversationsTable,
c,
where: 'jid = ?',
whereArgs: [jid],
);
final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
var newConversation = Conversation.fromDatabaseJson(
result,
rosterItem != null,
rosterItem?.subscription ?? 'none',
lastMessage,
);
// Copy over the old lastMessage if a new one was not set // Copy over the old lastMessage if a new one was not set
if (conversation.lastMessage != null && lastMessage == null) { if (conversation.lastMessage != null && lastMessage == null) {
@@ -133,8 +206,9 @@ class ConversationService {
return newConversation; return newConversation;
} }
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the /// Creates a [Conversation] inside the database given the data. This is so that the
/// cache. /// [Conversation] object can carry its database id.
///
/// To prevent issues with the cache, only call from within /// To prevent issues with the cache, only call from within
/// [ConversationService.createOrUpdateConversation]. /// [ConversationService.createOrUpdateConversation].
Future<Conversation> addConversationFromData( Future<Conversation> addConversationFromData(
@@ -148,28 +222,34 @@ class ConversationService {
bool open, bool open,
bool muted, bool muted,
bool encrypted, bool encrypted,
int sharedMediaAmount,
String? contactId, String? contactId,
String? contactAvatarPath, String? contactAvatarPath,
String? contactDisplayName, String? contactDisplayName,
) async { ) async {
final newConversation = final rosterItem =
await GetIt.I.get<DatabaseService>().addConversationFromData( await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
title, final newConversation = Conversation(
lastMessage, title,
type, lastMessage,
avatarUrl, avatarUrl,
jid, jid,
unreadCounter, unreadCounter,
lastChangeTimestamp, type,
open, lastChangeTimestamp,
muted, open,
encrypted, rosterItem != null && !rosterItem.pseudoRosterItem,
sharedMediaAmount, rosterItem?.subscription ?? 'none',
contactId, muted,
contactAvatarPath, encrypted,
contactDisplayName, ChatState.gone,
); contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
);
await GetIt.I.get<DatabaseService>().database.insert(
conversationsTable,
newConversation.toDatabaseJson(),
);
if (_conversationCache != null) { if (_conversationCache != null) {
_conversationCache![newConversation.jid] = newConversation; _conversationCache![newConversation.jid] = newConversation;

View File

@@ -58,11 +58,11 @@ class CryptographyService {
return EncryptionResult( return EncryptionResult(
key, key,
iv, iv,
<String, String>{ <HashFunction, String>{
hashSha256: base64Encode(result.plaintextHash), HashFunction.sha256: base64Encode(result.plaintextHash),
}, },
<String, String>{ <HashFunction, String>{
hashSha256: base64Encode(result.ciphertextHash), HashFunction.sha256: base64Encode(result.ciphertextHash),
}, },
); );
} }
@@ -76,8 +76,8 @@ class CryptographyService {
SFSEncryptionType encryption, SFSEncryptionType encryption,
List<int> key, List<int> key,
List<int> iv, List<int> iv,
Map<String, String> plaintextHashes, Map<HashFunction, String> plaintextHashes,
Map<String, String> ciphertextHashes, Map<HashFunction, String> ciphertextHashes,
) async { ) async {
_log.finest('Beginning decryption for $source'); _log.finest('Beginning decryption for $source');
final result = await MoxplatformPlugin.crypto.encryptFile( final result = await MoxplatformPlugin.crypto.encryptFile(
@@ -94,7 +94,7 @@ class CryptographyService {
var passedPlaintextIntegrityCheck = true; var passedPlaintextIntegrityCheck = true;
var passedCiphertextIntegrityCheck = true; var passedCiphertextIntegrityCheck = true;
for (final entry in plaintextHashes.entries) { for (final entry in plaintextHashes.entries) {
if (entry.key == hashSha256) { if (entry.key == HashFunction.sha256) {
if (base64Encode(result!.plaintextHash) != entry.value) { if (base64Encode(result!.plaintextHash) != entry.value) {
passedPlaintextIntegrityCheck = false; passedPlaintextIntegrityCheck = false;
} else { } else {
@@ -105,7 +105,7 @@ class CryptographyService {
} }
} }
for (final entry in ciphertextHashes.entries) { for (final entry in ciphertextHashes.entries) {
if (entry.key == hashSha256) { if (entry.key == HashFunction.sha256) {
if (base64Encode(result!.ciphertextHash) != entry.value) { if (base64Encode(result!.ciphertextHash) != entry.value) {
passedCiphertextIntegrityCheck = false; passedCiphertextIntegrityCheck = false;
} else { } else {

View File

@@ -1,156 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:cryptography/cryptography.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/cryptography/types.dart';
Future<List<int>> hashFileImpl(HashRequest request) async {
final data = await File(request.path).readAsBytes();
return CryptographicHashManager.hashFromData(data, request.hash);
}
Future<EncryptionResult> encryptFileImpl(EncryptionRequest request) async {
Cipher algorithm;
switch (request.encryption) {
case SFSEncryptionType.aes128GcmNoPadding:
algorithm = AesGcm.with128bits();
break;
case SFSEncryptionType.aes256GcmNoPadding:
algorithm = AesGcm.with256bits();
break;
case SFSEncryptionType.aes256CbcPkcs7:
// TODO(Unknown): Implement
throw Exception();
// ignore: dead_code
break;
}
// Generate a key and an IV for the file
final key = await algorithm.newSecretKey();
final iv = algorithm.newNonce();
final plaintext = await File(request.source).readAsBytes();
final secretBox = await algorithm.encrypt(
plaintext,
secretKey: key,
nonce: iv,
);
final ciphertext = [
...secretBox.cipherText,
...secretBox.mac.bytes,
];
// Write the file
await File(request.dest).writeAsBytes(ciphertext);
return EncryptionResult(
await key.extractBytes(),
iv,
{
hashSha256: base64Encode(
await CryptographicHashManager.hashFromData(
plaintext,
HashFunction.sha256,
),
),
},
{
hashSha256: base64Encode(
await CryptographicHashManager.hashFromData(
ciphertext,
HashFunction.sha256,
),
),
},
);
}
// TODO(PapaTutuWawa): Somehow fail when the ciphertext hash is not matching the provided data
Future<DecryptionResult> decryptFileImpl(DecryptionRequest request) async {
Cipher algorithm;
switch (request.encryption) {
case SFSEncryptionType.aes128GcmNoPadding:
algorithm = AesGcm.with128bits();
break;
case SFSEncryptionType.aes256GcmNoPadding:
algorithm = AesGcm.with256bits();
break;
case SFSEncryptionType.aes256CbcPkcs7:
// TODO(Unknown): Implement
throw Exception();
// ignore: dead_code
break;
}
final ciphertextRaw = await File(request.source).readAsBytes();
final mac = List<int>.empty(growable: true);
final ciphertext = List<int>.empty(growable: true);
// TODO(PapaTutuWawa): Somehow handle aes256CbcPkcs7
if (request.encryption == SFSEncryptionType.aes128GcmNoPadding ||
request.encryption == SFSEncryptionType.aes256GcmNoPadding) {
mac.addAll(ciphertextRaw.sublist(ciphertextRaw.length - 16));
ciphertext.addAll(ciphertextRaw.sublist(0, ciphertextRaw.length - 16));
}
var passedCiphertextIntegrityCheck = true;
var passedPlaintextIntegrityCheck = true;
// Try to find one hash we can verify
for (final entry in request.ciphertextHashes.entries) {
if ([hashSha256, hashSha512, hashBlake2b512].contains(entry.key)) {
final hash = await CryptographicHashManager.hashFromData(
ciphertext,
hashFunctionFromName(entry.key),
);
if (base64Encode(hash) == entry.value) {
passedCiphertextIntegrityCheck = true;
} else {
passedCiphertextIntegrityCheck = false;
}
break;
}
}
final secretBox = SecretBox(
ciphertext,
nonce: request.iv,
mac: Mac(mac),
);
try {
final data = await algorithm.decrypt(
secretBox,
secretKey: SecretKey(request.key),
);
for (final entry in request.plaintextHashes.entries) {
if ([hashSha256, hashSha512, hashBlake2b512].contains(entry.key)) {
final hash = await CryptographicHashManager.hashFromData(
data,
hashFunctionFromName(entry.key),
);
if (base64Encode(hash) == entry.value) {
passedPlaintextIntegrityCheck = true;
} else {
passedPlaintextIntegrityCheck = false;
}
break;
}
}
await File(request.dest).writeAsBytes(data);
} catch (_) {
return DecryptionResult(
false,
passedPlaintextIntegrityCheck,
passedCiphertextIntegrityCheck,
);
}
return DecryptionResult(
true,
passedPlaintextIntegrityCheck,
passedCiphertextIntegrityCheck,
);
}

View File

@@ -12,8 +12,8 @@ class EncryptionResult {
final List<int> key; final List<int> key;
final List<int> iv; final List<int> iv;
final Map<String, String> plaintextHashes; final Map<HashFunction, String> plaintextHashes;
final Map<String, String> ciphertextHashes; final Map<HashFunction, String> ciphertextHashes;
} }
@immutable @immutable
@@ -52,13 +52,6 @@ class DecryptionRequest {
final SFSEncryptionType encryption; final SFSEncryptionType encryption;
final List<int> key; final List<int> key;
final List<int> iv; final List<int> iv;
final Map<String, String> plaintextHashes; final Map<HashFunction, String> plaintextHashes;
final Map<String, String> ciphertextHashes; final Map<HashFunction, String> ciphertextHashes;
}
@immutable
class HashRequest {
const HashRequest(this.path, this.hash);
final String path;
final HashFunction hash;
} }

View File

@@ -16,6 +16,9 @@ const stickersTable = 'Stickers';
const stickerPacksTable = 'StickerPacks'; const stickerPacksTable = 'StickerPacks';
const blocklistTable = 'Blocklist'; const blocklistTable = 'Blocklist';
const subscriptionsTable = 'SubscriptionRequests'; const subscriptionsTable = 'SubscriptionRequests';
const fileMetadataTable = 'FileMetadata';
const fileMetadataHashesTable = 'FileMetadataHashes';
const reactionsTable = 'Reactions';
const typeString = 0; const typeString = 0;
const typeInt = 1; const typeInt = 1;

View File

@@ -18,8 +18,7 @@ Future<void> createDatabase(Database db, int version) async {
); );
// Messages // Messages
await db.execute( await db.execute('''
'''
CREATE TABLE $messagesTable ( CREATE TABLE $messagesTable (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL, sender TEXT NOT NULL,
@@ -27,41 +26,75 @@ Future<void> createDatabase(Database db, int version) async {
timestamp INTEGER NOT NULL, timestamp INTEGER NOT NULL,
sid TEXT NOT NULL, sid TEXT NOT NULL,
conversationJid TEXT NOT NULL, conversationJid TEXT NOT NULL,
isMedia INTEGER NOT NULL,
isFileUploadNotification INTEGER NOT NULL, isFileUploadNotification INTEGER NOT NULL,
encrypted INTEGER NOT NULL, encrypted INTEGER NOT NULL,
errorType INTEGER, errorType INTEGER,
warningType INTEGER, warningType INTEGER,
mediaUrl TEXT,
mediaType TEXT,
thumbnailData TEXT,
mediaWidth INTEGER,
mediaHeight INTEGER,
srcUrl TEXT,
key TEXT,
iv TEXT,
encryptionScheme TEXT,
received INTEGER, received INTEGER,
displayed INTEGER, displayed INTEGER,
acked INTEGER, acked INTEGER,
originId TEXT, originId TEXT,
quote_id INTEGER, quote_id INTEGER,
filename TEXT, file_metadata_id TEXT,
plaintextHashes TEXT,
ciphertextHashes TEXT,
isDownloading INTEGER NOT NULL, isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL, isUploading INTEGER NOT NULL,
mediaSize INTEGER,
isRetracted INTEGER, isRetracted INTEGER,
isEdited INTEGER NOT NULL, isEdited INTEGER NOT NULL,
reactions TEXT NOT NULL,
containsNoStore INTEGER NOT NULL, containsNoStore INTEGER NOT NULL,
stickerPackId TEXT, stickerPackId TEXT,
stickerHashKey TEXT,
pseudoMessageType INTEGER, pseudoMessageType INTEGER,
pseudoMessageData TEXT, pseudoMessageData TEXT,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id) CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
)''', CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''');
await db.execute(
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
);
// Reactions
await db.execute('''
CREATE TABLE $reactionsTable (
senderJid TEXT NOT NULL,
emoji TEXT NOT NULL,
message_id INTEGER NOT NULL,
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
ON DELETE CASCADE
)''');
await db.execute(
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
);
// File metadata
await db.execute('''
CREATE TABLE $fileMetadataTable (
id TEXT NOT NULL PRIMARY KEY,
path TEXT,
sourceUrls TEXT,
mimeType TEXT,
thumbnailType TEXT,
thumbnailData TEXT,
width INTEGER,
height INTEGER,
plaintextHashes TEXT,
encryptionKey TEXT,
encryptionIv TEXT,
encryptionScheme TEXT,
cipherTextHashes TEXT,
filename TEXT NOT NULL,
size INTEGER
)''');
await db.execute('''
CREATE TABLE $fileMetadataHashesTable (
algorithm TEXT NOT NULL,
value TEXT NOT NULL,
id TEXT NOT NULL,
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
ON DELETE CASCADE
)''');
await db.execute(
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
); );
// Conversations // Conversations
@@ -78,7 +111,6 @@ Future<void> createDatabase(Database db, int version) async {
muted INTEGER NOT NULL, muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL, encrypted INTEGER NOT NULL,
lastMessageId INTEGER, lastMessageId INTEGER,
sharedMediaAmount INTEGER NOT NULL,
contactId TEXT, contactId TEXT,
contactAvatarPath TEXT, contactAvatarPath TEXT,
contactDisplayName TEXT, contactDisplayName TEXT,
@@ -87,6 +119,9 @@ Future<void> createDatabase(Database db, int version) async {
ON DELETE SET NULL ON DELETE SET NULL
)''', )''',
); );
await db.execute(
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
);
// Contacts // Contacts
await db.execute(''' await db.execute('''
@@ -95,21 +130,6 @@ Future<void> createDatabase(Database db, int version) async {
jid TEXT NOT NULL jid TEXT NOT NULL
)'''); )''');
// Shared media
await db.execute(
'''
CREATE TABLE $mediaTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path TEXT NOT NULL,
mime TEXT,
timestamp INTEGER NOT NULL,
conversation_jid TEXT NOT NULL,
message_id INTEGER,
FOREIGN KEY (conversation_jid) REFERENCES $conversationsTable (jid),
FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
)''',
);
// Roster // Roster
await db.execute( await db.execute(
''' '''
@@ -134,19 +154,14 @@ Future<void> createDatabase(Database db, int version) async {
await db.execute( await db.execute(
''' '''
CREATE TABLE $stickersTable ( CREATE TABLE $stickersTable (
hashKey TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
mediaType TEXT NOT NULL, desc TEXT NOT NULL,
desc TEXT NOT NULL, suggests TEXT NOT NULL,
size INTEGER NOT NULL, file_metadata_id TEXT NOT NULL,
width INTEGER, stickerPackId TEXT NOT NULL,
height INTEGER,
hashes TEXT NOT NULL,
urlSources TEXT NOT NULL,
path TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
suggests TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id) CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE ON DELETE CASCADE,
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''', )''',
); );
await db.execute( await db.execute(

File diff suppressed because it is too large Load Diff

View File

@@ -35,3 +35,17 @@ ConversationType stringToConversationType(String type) {
} }
} }
} }
/// Given a map [map], extract all key-value pairs from [map] where the key starts with
/// [prefix]. Combine those key-value pairs into a new map, where the leading [prefix]
/// is removed from all key names.
Map<String, T> getPrefixedSubMap<T>(Map<String, T> map, String prefix) {
return Map<String, T>.fromEntries(
map.entries.where((entry) => entry.key.startsWith(prefix)).map(
(entry) => MapEntry<String, T>(
entry.key.substring(prefix.length),
entry.value,
),
),
);
}

View File

@@ -0,0 +1,44 @@
import 'package:logging/logging.dart';
/// A function to be called when a migration should be performed.
typedef DatabaseMigrationCallback<T> = Future<void> Function(T);
/// This class represents a single database migration.
class DatabaseMigration<T> {
const DatabaseMigration(this.version, this.migration);
/// The version this migration upgrades the database to.
final int version;
/// The migration callback. Called the the database version is less than [version].
final DatabaseMigrationCallback<T> migration;
}
/// Given the database [db] with the current version [version], goes through the list of
/// migrations [migrations] and applies all migrations with a version greater than
/// [version]. [migrations] is sorted before usage.
///
/// NOTE: This entire setup is written as a generic to make testing easier. We cannot easily
/// mock, or better "instantiate", a Database object. Thus, to avoid having nullable
/// database argument, just pass in whatever (the tests use an integer).
Future<void> runMigrations<T>(
Logger log,
T db,
List<DatabaseMigration<T>> migrations,
int version,
) async {
final sortedMigrations = List<DatabaseMigration<T>>.from(migrations)
..sort(
(a, b) => a.version.compareTo(b.version),
);
var currentVersion = version;
for (final migration in sortedMigrations) {
if (version < migration.version) {
log.info(
'Running database migration $currentVersion -> ${migration.version}',
);
await migration.migration(db);
currentVersion = migration.version;
}
}
}

View File

@@ -0,0 +1,226 @@
import 'dart:convert';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV31ToV32(Database db) async {
// Create the tracking table
await db.execute('''
CREATE TABLE $fileMetadataTable (
id TEXT NOT NULL PRIMARY KEY,
path TEXT,
sourceUrls TEXT,
mimeType TEXT,
thumbnailType TEXT,
thumbnailData TEXT,
width INTEGER,
height INTEGER,
plaintextHashes TEXT,
encryptionKey TEXT,
encryptionIv TEXT,
encryptionScheme TEXT,
cipherTextHashes TEXT,
filename TEXT NOT NULL,
size INTEGER
)''');
await db.execute('''
CREATE TABLE $fileMetadataHashesTable (
algorithm TEXT NOT NULL,
value TEXT NOT NULL,
id TEXT NOT NULL,
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
ON DELETE CASCADE
)''');
// Add the file_metadata_id column
await db.execute(
'ALTER TABLE $messagesTable ADD COLUMN file_metadata_id TEXT DEFAULT NULL;',
);
// Migrate the media messages' attributes to new table
final messages = await db.query(
messagesTable,
where: 'isMedia = ${boolToInt(true)}',
);
for (final message in messages) {
// Do we know of a hash?
String id;
if (message['plaintextHashes'] != null) {
// Plaintext hashes available (SFS)
final plaintextHashes = deserializeHashMap(
message['plaintextHashes']! as String,
);
final result = await db.query(
fileMetadataHashesTable,
where: 'algorithm = ? AND value = ?',
whereArgs: [
plaintextHashes.entries.first.key,
plaintextHashes.entries.first.value,
],
limit: 1,
);
if (result.isEmpty) {
final metadata = FileMetadata(
getStrongestHashFromMap(plaintextHashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
message['mediaUrl'] as String?,
message['srcUrl'] != null ? [message['srcUrl']! as String] : null,
message['mediaType'] as String?,
message['mediaSize'] as int?,
message['thumbnailData'] != null ? 'blurhash' : null,
message['thumbnailData'] as String?,
message['mediaWidth'] as int?,
message['mediaHeight'] as int?,
plaintextHashes,
message['key'] as String?,
message['iv'] as String?,
message['encryptionScheme'] as String?,
message['plaintextHashes'] == null
? null
: deserializeHashMap(message['ciphertextHashes']! as String),
message['filename']! as String,
);
// Create the metadata
await db.insert(
fileMetadataTable,
metadata.toDatabaseJson(),
);
id = metadata.id;
} else {
id = result[0]['id']! as String;
}
} else {
// No plaintext hashes are available (OOB data)
int? size;
int? height;
int? width;
Map<HashFunction, String>? hashes;
String? filePath;
String? urlSource;
String? mediaType;
String? filename;
if (message['filename'] == null) {
// We are dealing with a sticker
assert(
message['stickerPackId'] != null,
'The message must contain a sticker',
);
assert(
message['stickerHashKey'] != null,
'The message must contain a sticker',
);
final sticker = (await db.query(
stickersTable,
where: 'stickerPackId = ? AND hashKey = ?',
whereArgs: [message['stickerPackId'], message['stickerHashKey']],
limit: 1,
))
.first;
size = sticker['size']! as int;
width = sticker['width'] as int?;
height = sticker['height'] as int?;
hashes = deserializeHashMap(sticker['hashes']! as String);
filePath = sticker['path']! as String;
urlSource =
((jsonDecode(sticker['urlSources']! as String) as List<dynamic>)
.cast<String>())
.first;
mediaType = sticker['mediaType']! as String;
filename = path.basename(sticker['path']! as String);
} else {
size = message['mediaSize'] as int?;
width = message['mediaWidth'] as int?;
height = message['mediaHeight'] as int?;
filePath = message['mediaUrl'] as String?;
urlSource = message['srcUrl'] as String?;
mediaType = message['mediaType'] as String?;
filename = message['filename'] as String?;
}
final metadata = FileMetadata(
DateTime.now().millisecondsSinceEpoch.toString(),
filePath,
urlSource != null ? [urlSource] : null,
mediaType,
size,
message['thumbnailData'] != null ? 'blurhash' : null,
message['thumbnailData'] as String?,
width,
height,
hashes,
message['key'] as String?,
message['iv'] as String?,
message['encryptionScheme'] as String?,
null,
filename!,
);
// Create the metadata
await db.insert(
fileMetadataTable,
metadata.toDatabaseJson(),
);
id = metadata.id;
}
// Update the message
await db.update(
messagesTable,
{
'file_metadata_id': id,
},
where: 'id = ?',
whereArgs: [message['id']],
);
}
// Remove columns and add foreign key
await db.execute(
'''
CREATE TABLE ${messagesTable}_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
body TEXT,
timestamp INTEGER NOT NULL,
sid TEXT NOT NULL,
conversationJid TEXT NOT NULL,
isFileUploadNotification INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
errorType INTEGER,
warningType INTEGER,
received INTEGER,
displayed INTEGER,
acked INTEGER,
originId TEXT,
quote_id INTEGER,
file_metadata_id TEXT,
isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL,
isRetracted INTEGER,
isEdited INTEGER NOT NULL,
reactions TEXT NOT NULL,
containsNoStore INTEGER NOT NULL,
stickerPackId TEXT,
stickerHashKey TEXT,
pseudoMessageType INTEGER,
pseudoMessageData TEXT,
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''',
);
await db.execute(
'INSERT INTO ${messagesTable}_new SELECT id, sender, body, timestamp, sid, conversationJid, isFileUploadNotification, encrypted, errorType, warningType, received, displayed, acked, originId, quote_id, file_metadata_id, isDownloading, isUploading, isRetracted, isEdited, reactions, containsNoStore, stickerPackId, stickerHashKey, pseudoMessageType, pseudoMessageData FROM $messagesTable',
);
await db.execute('DROP TABLE $messagesTable');
await db.execute(
'ALTER TABLE ${messagesTable}_new RENAME TO $messagesTable;',
);
}

View File

@@ -0,0 +1,24 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV36ToV37(Database db) async {
// Queries against messages by id (and sid/originId happen regularly)
await db.execute(
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
);
// Conversations are often queried by their jid
await db.execute(
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
);
// Reactions must be quickly queried
await db.execute(
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
);
// File metadata should also be quickly queriable by its id
await db.execute(
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
);
}

View File

@@ -0,0 +1,60 @@
import 'dart:convert';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV34ToV35(Database db) async {
// Create the table
await db.execute('''
CREATE TABLE $reactionsTable (
senderJid TEXT NOT NULL,
emoji TEXT NOT NULL,
message_id INTEGER NOT NULL,
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
ON DELETE CASCADE
)''');
// Figure out our JID
final rawJid = await db.query(
xmppStateTable,
where: "key = 'jid'",
limit: 1,
);
String? jid;
if (rawJid.isNotEmpty) {
jid = rawJid.first['value']! as String;
}
// Migrate messages
final messages = await db.query(
messagesTable,
where: "reactions IS NOT '[]'",
);
for (final message in messages) {
final reactions =
(jsonDecode(message['reactions']! as String) as List<dynamic>)
.cast<Map<String, Object?>>();
for (final reaction in reactions) {
final senders = [
...reaction['senders']! as List<String>,
if (intToBool(reaction['reactedBySelf']! as int) && jid != null) jid,
];
for (final sender in senders) {
await db.insert(
reactionsTable,
{
'senderJid': sender,
'emoji': reaction['emoji']! as String,
'message_id': message['id']! as int,
},
);
}
}
}
// Remove the column
await db.execute('ALTER TABLE $messagesTable DROP COLUMN reactions');
}

View File

@@ -0,0 +1,15 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV35ToV36(Database db) async {
await db.execute('DROP TABLE $reactionsTable');
await db.execute('''
CREATE TABLE $reactionsTable (
senderJid TEXT NOT NULL,
emoji TEXT NOT NULL,
message_id INTEGER NOT NULL,
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
ON DELETE CASCADE
)''');
}

View File

@@ -0,0 +1,14 @@
import 'package:moxxyv2/service/database/constants.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV33ToV34(Database db) async {
// Remove the shared media counter...
await db.execute(
'ALTER TABLE $conversationsTable DROP COLUMN sharedMediaAmount',
);
// ... and the entire table.
await db.execute(
'DROP TABLE $mediaTable',
);
}

View File

@@ -0,0 +1,113 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:path/path.dart' as path;
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<void> upgradeFromV32ToV33(Database db) async {
final stickers = await db.query(stickersTable);
await db.execute(
'''
CREATE TABLE ${stickersTable}_new (
id TEXT PRIMARY KEY,
desc TEXT NOT NULL,
suggests TEXT NOT NULL,
file_metadata_id TEXT NOT NULL,
stickerPackId TEXT NOT NULL,
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
ON DELETE CASCADE,
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''',
);
// Mapping stickerHashKey -> fileMetadataId
final stickerHashMap = <String, String>{};
for (final sticker in stickers) {
final hashes =
(jsonDecode(sticker['hashes']! as String) as Map<String, dynamic>)
.cast<String, String>();
final buffer = StringBuffer();
for (var i = 0; i < hashes.length; i++) {
buffer.write('(algorithm = ? AND value = ?) AND');
}
final query = buffer.toString();
final rawFm = await db.query(
fileMetadataHashesTable,
where: query.substring(0, query.length - 1 - 3),
whereArgs: hashes.entries
.map<List<String>>((entry) => [entry.key, entry.value])
.flattened
.toList(),
limit: 1,
);
String fileMetadataId;
if (rawFm.isEmpty) {
// Create the metadata
fileMetadataId = DateTime.now().toString();
await db.insert(
fileMetadataTable,
{
'id': fileMetadataId,
'path': sticker['path']! as String,
'size': sticker['size']! as int,
'width': sticker['width'] as int?,
'height': sticker['height'] as int?,
'plaintextHashes': sticker['hashes']! as String,
'mimeType': sticker['mediaType']! as String,
'sourceUrls': sticker['urlSources'],
'filename': path.basename(sticker['path']! as String),
},
);
// Create hash pointers
for (final hashEntry in hashes.entries) {
await db.insert(
fileMetadataHashesTable,
{
'algorithm': hashEntry.key,
'value': hashEntry.value,
'id': fileMetadataId,
},
);
}
} else {
fileMetadataId = rawFm.first['id']! as String;
}
final hashKey = sticker['hashKey']! as String;
stickerHashMap[hashKey] = fileMetadataId;
await db.insert(
'${stickersTable}_new',
{
'id': hashKey,
'desc': sticker['desc']! as String,
'suggests': sticker['suggests']! as String,
'file_metadata_id': fileMetadataId,
'stickerPackId': sticker['stickerPackId']! as String,
},
);
}
// Rename the table
await db.execute('DROP TABLE $stickersTable');
await db.execute('ALTER TABLE ${stickersTable}_new RENAME TO $stickersTable');
// Migrate messages
for (final stickerEntry in stickerHashMap.entries) {
await db.update(
messagesTable,
{
'file_metadata_id': stickerEntry.value,
},
where: 'stickerHashKey = ?',
whereArgs: [stickerEntry.key],
);
}
// Remove the hash key from messages
await db.execute('ALTER TABLE $messagesTable DROP COLUMN stickerHashKey');
}

View File

@@ -7,9 +7,9 @@ import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/service/avatars.dart'; import 'package:moxxyv2/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart'; import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/contacts.dart'; import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/helpers.dart'; import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart'; import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
@@ -21,6 +21,7 @@ import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/notifications.dart'; import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart'; import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/stickers.dart'; import 'package:moxxyv2/service/stickers.dart';
@@ -32,13 +33,14 @@ import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/reaction.dart'; import 'package:moxxyv2/shared/models/reaction_group.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker; import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack; import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
import 'package:moxxyv2/shared/models/xmpp_state.dart'; import 'package:moxxyv2/shared/models/xmpp_state.dart';
import 'package:moxxyv2/shared/synchronized_queue.dart'; import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:permission_handler/permission_handler.dart'; //import 'package:permission_handler/permission_handler.dart';
void setupBackgroundEventHandler() { void setupBackgroundEventHandler() {
final handler = EventHandler() final handler = EventHandler()
@@ -97,6 +99,7 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist), EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages), EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia), EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
]); ]);
GetIt.I.registerSingleton<EventHandler>(handler); GetIt.I.registerSingleton<EventHandler>(handler);
@@ -151,17 +154,18 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
await GetIt.I.get<RosterService>().loadRosterFromDatabase(); await GetIt.I.get<RosterService>().loadRosterFromDatabase();
// Check some permissions // Check some permissions
final storagePerm = await Permission.storage.status; // TODO(Unknown): Do we still need this permission?
final permissions = List<int>.empty(growable: true); // final storagePerm = await Permission.storage.status;
if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) { // final permissions = List<int>.empty(growable: true);
permissions.add(Permission.storage.value); // if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
// permissions.add(Permission.storage.value);
await xss.modifyXmppState( // await xss.modifyXmppState(
(state) => state.copyWith( // (state) => state.copyWith(
askedStoragePermission: true, // askedStoragePermission: true,
), // ),
); // );
} // }
return PreStartDoneEvent( return PreStartDoneEvent(
state: 'logged_in', state: 'logged_in',
@@ -169,11 +173,12 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
displayName: state.displayName ?? state.jid!.split('@').first, displayName: state.displayName ?? state.jid!.split('@').first,
avatarUrl: state.avatarUrl, avatarUrl: state.avatarUrl,
avatarHash: state.avatarHash, avatarHash: state.avatarHash,
permissionsToRequest: permissions, permissionsToRequest: [],
preferences: preferences, preferences: preferences,
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()) conversations:
.where((c) => c.open) (await GetIt.I.get<ConversationService>().loadConversations())
.toList(), .where((c) => c.open)
.toList(),
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(), roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
stickers: await GetIt.I.get<StickersService>().getStickerPacks(), stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
); );
@@ -240,7 +245,6 @@ Future<void> performAddConversation(
true, true,
preferences.defaultMuteState, preferences.defaultMuteState,
preferences.enableOmemoByDefault, preferences.enableOmemoByDefault,
0,
contactId, contactId,
await css.getProfilePicturePathForJid(command.jid), await css.getProfilePicturePathForJid(command.jid),
await css.getContactDisplayName(contactId), await css.getContactDisplayName(contactId),
@@ -403,7 +407,9 @@ Future<void> performSetPreferences(
final pm = GetIt.I final pm = GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getManagerById<PubSubManager>(pubsubManager)!; .getManagerById<PubSubManager>(pubsubManager)!;
final ownJid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!; final ownJid = JID.fromString(
(await GetIt.I.get<XmppStateService>().getXmppState()).jid!,
);
if (command.preferences.isStickersNodePublic && if (command.preferences.isStickersNodePublic &&
!oldPrefs.isStickersNodePublic) { !oldPrefs.isStickersNodePublic) {
// Set to open // Set to open
@@ -471,7 +477,6 @@ Future<void> performAddContact(
true, true,
prefs.defaultMuteState, prefs.defaultMuteState,
prefs.enableOmemoByDefault, prefs.enableOmemoByDefault,
0,
contactId, contactId,
await css.getProfilePicturePathForJid(jid), await css.getProfilePicturePathForJid(jid),
await css.getContactDisplayName(contactId), await css.getContactDisplayName(contactId),
@@ -562,26 +567,33 @@ Future<void> performRequestDownload(
); );
sendEvent(MessageUpdatedEvent(message: message)); sendEvent(MessageUpdatedEvent(message: message));
final metadata = await peekFile(command.message.srcUrl!); final fileMetadata = command.message.fileMetadata!;
final metadata = await peekFile(fileMetadata.sourceUrls!.first);
// TODO(Unknown): Maybe deduplicate with the code in the xmpp service // TODO(Unknown): Maybe deduplicate with the code in the xmpp service
// NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails // NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails
// for ".../aaaaaaaaa", in which case we would've failed anyways. // for ".../aaaaaaaaa", in which case we would've failed anyways.
final ext = message.srcUrl!.split('.').last; final ext = fileMetadata.sourceUrls!.first.split('.').last;
final mimeGuess = metadata.mime ?? guessMimeTypeFromExtension(ext); final mimeGuess = metadata.mime ?? guessMimeTypeFromExtension(ext);
await srv.downloadFile( await srv.downloadFile(
FileDownloadJob( FileDownloadJob(
MediaFileLocation( MediaFileLocation(
message.srcUrl!, fileMetadata.sourceUrls!,
message.filename ?? filenameFromUrl(message.srcUrl!), fileMetadata.filename,
message.encryptionScheme, fileMetadata.encryptionScheme,
message.key != null ? base64Decode(message.key!) : null, fileMetadata.encryptionKey != null
message.iv != null ? base64Decode(message.iv!) : null, ? base64Decode(fileMetadata.encryptionKey!)
message.plaintextHashes, : null,
message.ciphertextHashes, fileMetadata.encryptionIv != null
? base64Decode(fileMetadata.encryptionIv!)
: null,
fileMetadata.plaintextHashes,
fileMetadata.ciphertextHashes,
null,
), ),
message.id, message.id,
message.fileMetadata!.id,
message.conversationJid, message.conversationJid,
mimeGuess, mimeGuess,
), ),
@@ -655,6 +667,9 @@ Future<void> performSendChatState(
// Only send chat states if the users wants to send them // Only send chat states if the users wants to send them
if (!prefs.sendChatMarkers) return; if (!prefs.sendChatMarkers) return;
// Only send chat states when we're connected
if (!(await GetIt.I.get<ConnectivityService>().hasConnection())) return;
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<XmppConnection>();
if (command.jid != '') { if (command.jid != '') {
@@ -927,34 +942,32 @@ Future<void> performAddMessageReaction(
AddReactionToMessageCommand command, { AddReactionToMessageCommand command, {
dynamic extra, dynamic extra,
}) async { }) async {
final ms = GetIt.I.get<MessageService>(); final rs = GetIt.I.get<ReactionsService>();
final conn = GetIt.I.get<XmppConnection>(); final msg = await rs.addNewReaction(
final msg = command.messageId,
await ms.getMessageById(command.conversationJid, command.messageId); command.conversationJid,
assert(msg != null, 'The message must be found'); command.emoji,
);
// Update the state if (msg == null) {
final reactions = List<Reaction>.from(msg!.reactions); return;
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
if (i == -1) {
reactions.add(Reaction([], command.emoji, true));
} else {
reactions[i] = reactions[i].copyWith(reactedBySelf: true);
} }
await ms.updateMessage(msg.id, reactions: reactions);
// Collect all our reactions
final ownReactions =
reactions.where((r) => r.reactedBySelf).map((r) => r.emoji).toList();
if (command.conversationJid != '') { if (command.conversationJid != '') {
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
// Send the reaction // Send the reaction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage( GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!
.sendMessage(
MessageDetails( MessageDetails(
to: command.conversationJid, to: command.conversationJid,
messageReactions: MessageReactions( messageReactions: MessageReactions(
msg.originId ?? msg.sid, msg.originId ?? msg.sid,
ownReactions, await rs.getReactionsForMessageByJid(
command.messageId,
jid,
),
), ),
requestChatMarkers: false, requestChatMarkers: false,
messageProcessingHints: messageProcessingHints:
@@ -968,35 +981,32 @@ Future<void> performRemoveMessageReaction(
RemoveReactionFromMessageCommand command, { RemoveReactionFromMessageCommand command, {
dynamic extra, dynamic extra,
}) async { }) async {
final ms = GetIt.I.get<MessageService>(); final rs = GetIt.I.get<ReactionsService>();
final conn = GetIt.I.get<XmppConnection>(); final msg = await rs.removeReaction(
final msg = command.messageId,
await ms.getMessageById(command.conversationJid, command.messageId); command.conversationJid,
assert(msg != null, 'The message must be found'); command.emoji,
);
// Update the state if (msg == null) {
final reactions = List<Reaction>.from(msg!.reactions); return;
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
assert(i >= -1, 'The reaction must be found');
if (reactions[i].senders.isEmpty) {
reactions.removeAt(i);
} else {
reactions[i] = reactions[i].copyWith(reactedBySelf: false);
} }
await ms.updateMessage(msg.id, reactions: reactions);
// Collect all our reactions
final ownReactions =
reactions.where((r) => r.reactedBySelf).map((r) => r.emoji).toList();
if (command.conversationJid != '') { if (command.conversationJid != '') {
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
// Send the reaction // Send the reaction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage( GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!
.sendMessage(
MessageDetails( MessageDetails(
to: command.conversationJid, to: command.conversationJid,
messageReactions: MessageReactions( messageReactions: MessageReactions(
msg.originId ?? msg.sid, msg.originId ?? msg.sid,
ownReactions, await rs.getReactionsForMessageByJid(
command.messageId,
jid,
),
), ),
requestChatMarkers: false, requestChatMarkers: false,
messageProcessingHints: messageProcessingHints:
@@ -1042,21 +1052,13 @@ Future<void> performSendSticker(
SendStickerCommand command, { SendStickerCommand command, {
dynamic extra, dynamic extra,
}) async { }) async {
final xs = GetIt.I.get<XmppService>(); await GetIt.I.get<XmppService>().sendMessage(
final ss = GetIt.I.get<StickersService>(); body: command.sticker.desc,
recipients: [command.recipient],
final sticker = await ss.getStickerByHashKey( sticker: command.sticker,
command.stickerPackId, currentConversationJid: command.recipient,
command.stickerHashKey, quotedMessage: command.quotes,
); );
assert(sticker != null, 'Sticker not found');
await xs.sendMessage(
body: sticker!.desc,
recipients: [command.recipient],
sticker: sticker,
currentConversationJid: command.recipient,
);
} }
Future<void> performRemoveStickerPack( Future<void> performRemoveStickerPack(
@@ -1096,19 +1098,30 @@ Future<void> performFetchStickerPack(
.map( .map(
(s) => sticker.Sticker( (s) => sticker.Sticker(
'', '',
s.metadata.mediaType!,
s.metadata.desc!,
s.metadata.size!,
s.metadata.width,
s.metadata.height,
s.metadata.hashes,
s.sources
.whereType<StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList(),
'',
command.stickerPackId, command.stickerPackId,
s.metadata.desc!,
s.suggests, s.suggests,
FileMetadata(
'',
null,
s.sources
.whereType<StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList(),
s.metadata.mediaType,
s.metadata.size,
// TODO(Unknown): One day
null,
null,
s.metadata.width,
s.metadata.height,
s.metadata.hashes,
null,
null,
null,
null,
s.metadata.name ?? '',
),
), ),
) )
.toList(), .toList(),
@@ -1188,15 +1201,49 @@ Future<void> performGetPagedSharedMedia(
final id = extra as String; final id = extra as String;
final result = final result =
await GetIt.I.get<DatabaseService>().getPaginatedSharedMediaForJid( await GetIt.I.get<MessageService>().getPaginatedSharedMediaMessagesForJid(
command.conversationJid, command.conversationJid,
command.olderThan, command.olderThan,
command.timestamp, command.timestamp,
); );
sendEvent( sendEvent(
PagedSharedMediaResultEvent( PagedMessagesResultEvent(
media: result, messages: result,
),
id: id,
);
}
Future<void> performGetReactions(
GetReactionsForMessageCommand command, {
dynamic extra,
}) async {
final id = extra as String;
final reactionsRaw =
await GetIt.I.get<ReactionsService>().getReactionsForMessage(
command.messageId,
);
final reactionsMap = <String, List<String>>{};
for (final reaction in reactionsRaw) {
if (reactionsMap.containsKey(reaction.senderJid)) {
reactionsMap[reaction.senderJid]!.add(reaction.emoji);
} else {
reactionsMap[reaction.senderJid] = List<String>.from([reaction.emoji]);
}
}
sendEvent(
ReactionsForMessageResult(
reactions: reactionsMap.entries
.map(
(entry) => ReactionGroup(
entry.key,
entry.value,
),
)
.toList(),
), ),
id: id, id: id,
); );

340
lib/service/files.dart Normal file
View File

@@ -0,0 +1,340 @@
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
/// A class for returning whether a file metadata element was just created or retrieved.
class FileMetadataWrapper {
FileMetadataWrapper(
this.fileMetadata,
this.retrieved,
);
/// The file metadata.
FileMetadata fileMetadata;
/// Indicates whether the file metadata already exists (true) or
/// if it has been created (false).
bool retrieved;
}
/// Returns the strongest hash from [map], if [map] is not null. If no known hash is found
/// or [map] is null, returns null.
String? getStrongestHashFromMap(Map<HashFunction, String>? map) {
if (map == null) {
return null;
}
return map[HashFunction.blake2b512] ??
map[HashFunction.blake2b256] ??
map[HashFunction.sha3_512] ??
map[HashFunction.sha3_256] ??
map[HashFunction.sha512] ??
map[HashFunction.sha256];
}
/// Calculates the path for a given file with filename [filename] and the optional
/// plaintext hashes [hashes]. If the base directory for the file does not exist, then it
/// will be created.
Future<String> computeCachedPathForFile(
String filename,
Map<HashFunction, String>? hashes,
) async {
final basePath = path.join(
(await getApplicationDocumentsDirectory()).path,
'media',
);
final baseDir = Directory(basePath);
if (!baseDir.existsSync()) {
await baseDir.create(recursive: true);
}
// Keep the extension of the file. Otherwise Android will be really confused
// as to what it should open the file with.
final ext = path.extension(filename);
final hash = getStrongestHashFromMap(hashes)?.replaceAll('/', '_');
return path.join(
basePath,
hash != null
? '$hash.$ext'
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
);
}
class FilesService {
// Logging.
final Logger _log = Logger('FilesService');
Future<void> createMetadataHashEntries(
Map<HashFunction, String> plaintextHashes,
String metadataId,
) async {
final db = GetIt.I.get<DatabaseService>().database;
for (final hash in plaintextHashes.entries) {
await db.insert(
fileMetadataHashesTable,
{
'algorithm': hash.key.toName(),
'value': hash.value,
'id': metadataId,
},
);
}
}
Future<FileMetadata?> getFileMetadataFromFile(FileMetadata metadata) async {
final hash = metadata.plaintextHashes?[HashFunction.sha256] ??
await GetIt.I
.get<CryptographyService>()
.hashFile(metadata.path!, HashFunction.sha256);
final fm = await getFileMetadataFromHash({
HashFunction.sha256: hash,
});
if (fm != null) {
return fm;
}
final result = await addFileMetadataFromData(
metadata.copyWith(
plaintextHashes: {
...metadata.plaintextHashes ?? {},
HashFunction.sha256: hash,
},
),
);
await createMetadataHashEntries(result.plaintextHashes!, result.id);
return result;
}
Future<FileMetadata?> getFileMetadataFromHash(
Map<HashFunction, String>? plaintextHashes,
) async {
if (plaintextHashes?.isEmpty ?? true) {
return null;
}
final db = GetIt.I.get<DatabaseService>().database;
final values = List<String>.empty(growable: true);
final query = plaintextHashes!.entries.map((entry) {
values
..add(entry.key.toName())
..add(entry.value);
return '(algorithm = ? AND value = ?)';
}).join(' OR ');
final hashes = await db.query(
fileMetadataHashesTable,
where: query,
whereArgs: values,
limit: 1,
);
if (hashes.isEmpty) {
return null;
}
final result = await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [hashes[0]['id']! as String],
limit: 1,
);
if (result.isEmpty) {
return null;
}
return FileMetadata.fromDatabaseJson(result[0]);
}
/// Create a FileMetadata entry if we do not know the plaintext hashes described in
/// [location].
/// If we know of at least one hash, return that FileMetadata element.
///
/// If [createHashPointers] is true and we have to create a new FileMetadata element,
/// then also create the hash pointers, if plaintext hashes are specified. If no
/// plaintext hashes are specified or [createHashPointers] is false, no pointers will be
/// created.
Future<FileMetadataWrapper> createFileMetadataIfRequired(
MediaFileLocation location,
String? mimeType,
int? size,
Size? dimensions,
String? thubnailType,
String? thumbnailData, {
bool createHashPointers = true,
String? path,
}) async {
if (location.plaintextHashes?.isNotEmpty ?? false) {
final result = await getFileMetadataFromHash(location.plaintextHashes);
if (result != null) {
_log.finest('Not creating new metadata as we found the hash');
return FileMetadataWrapper(
result,
true,
);
}
}
final db = GetIt.I.get<DatabaseService>().database;
final fm = FileMetadata(
getStrongestHashFromMap(location.plaintextHashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
path,
location.urls,
mimeType,
size,
thubnailType,
thumbnailData,
dimensions?.width.toInt(),
dimensions?.height.toInt(),
location.plaintextHashes,
location.key != null ? base64Encode(location.key!) : null,
location.iv != null ? base64Encode(location.iv!) : null,
location.encryptionScheme,
location.ciphertextHashes,
location.filename,
);
await db.insert(fileMetadataTable, fm.toDatabaseJson());
if ((location.plaintextHashes?.isNotEmpty ?? false) && createHashPointers) {
await createMetadataHashEntries(
location.plaintextHashes!,
fm.id,
);
}
return FileMetadataWrapper(
fm,
false,
);
}
Future<void> removeFileMetadata(String id) async {
await GetIt.I.get<DatabaseService>().database.delete(
fileMetadataTable,
where: 'id = ?',
whereArgs: [id],
);
}
Future<FileMetadata> updateFileMetadata(
String id, {
Object? path = notSpecified,
int? size,
String? encryptionScheme,
String? encryptionKey,
String? encryptionIv,
List<String>? sourceUrls,
int? width,
int? height,
String? mimeType,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final m = <String, dynamic>{};
if (path != notSpecified) {
m['path'] = path as String?;
}
if (encryptionScheme != null) {
m['encryptionScheme'] = encryptionScheme;
}
if (size != null) {
m['size'] = size;
}
if (encryptionKey != null) {
m['encryptionKey'] = encryptionKey;
}
if (encryptionIv != null) {
m['encryptionIv'] = encryptionIv;
}
if (sourceUrls != null) {
m['sourceUrl'] = jsonEncode(sourceUrls);
}
if (width != null) {
m['width'] = width;
}
if (height != null) {
m['height'] = height;
}
if (mimeType != null) {
m['mimeType'] = mimeType;
}
if (plaintextHashes != null) {
m['plaintextHashes'] = jsonEncode(plaintextHashes);
}
if (ciphertextHashes != null) {
m['cipherTextHashes'] = jsonEncode(ciphertextHashes);
}
final result = await db.updateAndReturn(
fileMetadataTable,
m,
where: 'id = ?',
whereArgs: [id],
);
return FileMetadata.fromDatabaseJson(result);
}
/// Removes the file metadata described by [metadata] if it is referenced by exactly 0
/// messages and no stickers use this file. If the file is referenced by > 1 messages
/// or a sticker, does nothing.
Future<void> removeFileIfNotReferenced(FileMetadata metadata) async {
final db = GetIt.I.get<DatabaseService>().database;
final messagesCount = await db.count(
messagesTable,
'file_metadata_id = ?',
[metadata.id],
);
final stickersCount = await db.count(
stickersTable,
'file_metadata_id = ?',
[metadata.id],
);
if (messagesCount == 0 && stickersCount == 0) {
_log.finest(
'Removing file metadata as no stickers and no messages reference it',
);
await removeFileMetadata(metadata.id);
// Only remove the file if we have a path
if (metadata.path != null) {
try {
await File(metadata.path!).delete();
} catch (ex) {
_log.warning('Failed to remove file ${metadata.path!}: $ex');
}
} else {
_log.info('Not removing file as there is no path associated with it');
}
} else {
_log.info(
'Not removing file as $messagesCount messages and $stickersCount stickers reference this file',
);
}
}
Future<FileMetadata> addFileMetadataFromData(
FileMetadata metadata,
) async {
final result =
await GetIt.I.get<DatabaseService>().database.insertAndReturn(
fileMetadataTable,
metadata.toDatabaseJson(),
);
return FileMetadata.fromDatabaseJson(result);
}
}

View File

@@ -76,26 +76,27 @@ String xmppErrorToTranslatableString(XmppError error) {
return t.errors.login.unspecified; return t.errors.login.unspecified;
} }
String getStickerHashKeyType(Map<String, String> hashes) { HashFunction getStickerHashKeyType(Map<HashFunction, String> hashes) {
if (hashes.containsKey('blake2b-512')) { if (hashes.containsKey(HashFunction.blake2b512)) {
return 'blake2b-512'; return HashFunction.blake2b512;
} else if (hashes.containsKey('blake2b-512')) { } else if (hashes.containsKey(HashFunction.blake2b256)) {
return 'blake2b-256'; return HashFunction.blake2b256;
} else if (hashes.containsKey('sha3-512')) { } else if (hashes.containsKey(HashFunction.sha3_512)) {
return 'sha3-512'; return HashFunction.sha3_512;
} else if (hashes.containsKey('sha3-256')) { } else if (hashes.containsKey(HashFunction.sha3_256)) {
return 'sha3-256'; return HashFunction.sha3_256;
} else if (hashes.containsKey('sha3-256')) { } else if (hashes.containsKey(HashFunction.sha512)) {
return 'sha-512'; return HashFunction.sha512;
} else if (hashes.containsKey('sha-256')) { } else if (hashes.containsKey(HashFunction.sha256)) {
return 'sha-256'; return HashFunction.sha256;
} }
assert(false, 'No valid hash found'); assert(false, 'No valid hash found');
return ''; return HashFunction.sha256;
} }
String getStickerHashKey(Map<String, String> hashes) { // TODO(PapaTutuWawa): Replace with getStrongestHash
String getStickerHashKey(Map<HashFunction, String> hashes) {
final key = getStickerHashKeyType(hashes); final key = getStickerHashKeyType(hashes);
return '$key:${hashes[key]}'; return '$key:${hashes[key]}';
} }
@@ -131,16 +132,19 @@ String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
if (quotedMessage.isMedia) { if (quotedMessage.isMedia) {
// Create formatted size string, if size is stored // Create formatted size string, if size is stored
String quoteMessageSize; String quoteMessageSize;
if (quotedMessage.mediaSize != null && quotedMessage.mediaSize! > 0) { if (quotedMessage.fileMetadata!.size != null &&
quoteMessageSize = '(${fileSizeToString(quotedMessage.mediaSize!)}) '; quotedMessage.fileMetadata!.size! > 0) {
quoteMessageSize =
'(${fileSizeToString(quotedMessage.fileMetadata!.size!)}) ';
} else { } else {
quoteMessageSize = ''; quoteMessageSize = '';
} }
// Create media url string, or use body if no srcUrl is stored // Create media url string, or use body if no srcUrl is stored
String quotedMediaUrl; String quotedMediaUrl;
if (quotedMessage.srcUrl != null && quotedMessage.srcUrl!.isNotEmpty) { if (quotedMessage.fileMetadata!.sourceUrls != null &&
quotedMediaUrl = '${quotedMessage.srcUrl!}'; quotedMessage.fileMetadata!.sourceUrls!.first.isNotEmpty) {
quotedMediaUrl = '${quotedMessage.fileMetadata!.sourceUrls!.first}';
} else if (quotedMessage.body.isNotEmpty) { } else if (quotedMessage.body.isNotEmpty) {
quotedMediaUrl = '${quotedMessage.body}'; quotedMediaUrl = '${quotedMessage.body}';
} else { } else {

View File

@@ -1,47 +1,4 @@
import 'dart:io';
import 'package:external_path/external_path.dart';
import 'package:moxxyv2/service/httpfiletransfer/client.dart'; import 'package:moxxyv2/service/httpfiletransfer/client.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:path/path.dart' as path;
/// Calculates the path for a given file to be saved to and, if neccessary, create it.
Future<String> getDownloadPath(
String filename,
String conversationJid,
String? mime,
) async {
String type;
var prependMoxxy = true;
if (mime != null && ['image/', 'video/'].any((e) => mime.startsWith(e))) {
type = ExternalPath.DIRECTORY_PICTURES;
} else {
type = ExternalPath.DIRECTORY_DOWNLOADS;
prependMoxxy = false;
}
final externalDir =
await ExternalPath.getExternalStoragePublicDirectory(type);
final fileDirectory = prependMoxxy
? path.join(externalDir, 'Moxxy', conversationJid)
: externalDir;
final dir = Directory(fileDirectory);
if (!dir.existsSync()) {
await dir.create(recursive: true);
}
var i = 0;
while (true) {
final filenameSuffix = i == 0 ? '' : '($i)';
final suffixedFilename = filenameWithSuffix(filename, filenameSuffix);
final filePath = path.join(fileDirectory, suffixedFilename);
if (!File(filePath).existsSync()) {
return filePath;
}
i++;
}
}
/// Returns true if the request was successful based on [statusCode]. /// Returns true if the request was successful based on [statusCode].
/// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status /// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
@@ -49,8 +6,8 @@ bool isRequestOkay(int? statusCode) {
return statusCode != null && statusCode >= 200 && statusCode <= 399; return statusCode != null && statusCode >= 200 && statusCode <= 399;
} }
class FileMetadata { class FileUploadMetadata {
const FileMetadata({this.mime, this.size}); const FileUploadMetadata({this.mime, this.size});
final String? mime; final String? mime;
final int? size; final int? size;
} }
@@ -58,10 +15,10 @@ class FileMetadata {
/// Returns the size of the file at [url] in octets. If an error occurs or the server /// Returns the size of the file at [url] in octets. If an error occurs or the server
/// does not specify the Content-Length header, null is returned. /// does not specify the Content-Length header, null is returned.
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length /// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
Future<FileMetadata> peekFile(String url) async { Future<FileUploadMetadata> peekFile(String url) async {
final result = await peekUrl(Uri.parse(url)); final result = await peekUrl(Uri.parse(url));
return FileMetadata( return FileUploadMetadata(
mime: result?.contentType, mime: result?.contentType,
size: result?.contentLength, size: result?.contentLength,
); );

View File

@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:connectivity_plus/connectivity_plus.dart';
@@ -13,17 +12,17 @@ import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart'; import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/cryptography/types.dart'; import 'package:moxxyv2/service/cryptography/types.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client; import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart'; import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart'; import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/message.dart'; import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/notifications.dart'; import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/error_types.dart'; import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/warning_types.dart'; import 'package:moxxyv2/shared/warning_types.dart';
import 'package:path/path.dart' as pathlib; import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@@ -122,29 +121,25 @@ class HttpFileTransferService {
}); });
} }
Future<void> _copyFile(FileUploadJob job) async { Future<void> _copyFile(
for (final recipient in job.recipients) { FileUploadJob job,
final newPath = await getDownloadPath( String to,
pathlib.basename(job.path), ) async {
recipient, if (!File(to).existsSync()) {
job.mime, await File(job.path).copy(to);
);
await File(job.path).copy(newPath);
// Let the media scanner index the file // Let the media scanner index the file
MoxplatformPlugin.media.scanFile(newPath); MoxplatformPlugin.media.scanFile(to);
} else {
// Update the message _log.finest(
await GetIt.I.get<MessageService>().updateMessage( 'Skipping file copy on upload as file is already at media location',
job.messageMap[recipient]!.id, );
mediaUrl: newPath,
);
} }
} }
Future<void> _fileUploadFailed(FileUploadJob job, int error) async { Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
// Notify UI of upload failure // Notify UI of upload failure
for (final recipient in job.recipients) { for (final recipient in job.recipients) {
@@ -154,6 +149,19 @@ class HttpFileTransferService {
isUploading: false, isUploading: false,
); );
sendEvent(MessageUpdatedEvent(message: msg)); sendEvent(MessageUpdatedEvent(message: msg));
// Update the conversation list
final conversation = await cs.getConversationByJid(recipient);
if (conversation?.lastMessage?.id == msg.id) {
final newConversation = conversation!.copyWith(
lastMessage: msg,
);
// Update the cache
cs.setConversation(newConversation);
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
}
} }
await _pickNextUploadTask(); await _pickNextUploadTask();
@@ -233,20 +241,92 @@ class HttpFileTransferService {
} else { } else {
_log.fine('Upload was successful'); _log.fine('Upload was successful');
// Get hashes
StatelessFileSharingSource source;
final plaintextHashes = <HashFunction, String>{};
Map<HashFunction, String>? ciphertextHashes;
if (encryption != null) {
source = StatelessFileSharingEncryptedSource(
SFSEncryptionType.aes256GcmNoPadding,
encryption.key,
encryption.iv,
encryption.ciphertextHashes,
StatelessFileSharingUrlSource(slot.getUrl),
);
plaintextHashes.addAll(encryption.plaintextHashes);
ciphertextHashes = encryption.ciphertextHashes;
} else {
source = StatelessFileSharingUrlSource(slot.getUrl);
try {
plaintextHashes[HashFunction.sha256] = await GetIt.I
.get<CryptographyService>()
.hashFile(job.path, HashFunction.sha256);
} catch (ex) {
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
}
}
// Update the metadata
final filename = pathlib.basename(job.path);
final filePath = await computeCachedPathForFile(
filename,
plaintextHashes,
);
final metadataWrapper =
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
MediaFileLocation(
[slot.getUrl],
filename,
encryption != null
? SFSEncryptionType.aes256GcmNoPadding.toNamespace()
: null,
encryption?.key,
encryption?.iv,
plaintextHashes,
ciphertextHashes,
stat.size,
),
job.mime,
stat.size,
null,
// TODO(Unknown): job.thumbnails.first
null,
null,
path: filePath,
);
var metadata = metadataWrapper.fileMetadata;
// Remove the tempoary metadata if we already know the file
if (metadataWrapper.retrieved) {
// Only skip the copy if the existing file metadata has a path associated with it
if (metadataWrapper.fileMetadata.path != null) {
_log.fine(
'Uploaded file $filename is already tracked. Skipping copy.',
);
} else {
_log.fine(
'Uploaded file $filename is already tracked but has no path. Copying...',
);
await _copyFile(job, filePath);
metadata = await GetIt.I.get<FilesService>().updateFileMetadata(
metadata.id,
path: filePath,
);
}
} else {
_log.fine('Uploaded file $filename not tracked. Copying...');
await _copyFile(job, metadataWrapper.fileMetadata.path!);
}
const uuid = Uuid(); const uuid = Uuid();
for (final recipient in job.recipients) { for (final recipient in job.recipients) {
// Notify UI of upload completion // Notify UI of upload completion
var msg = await ms.updateMessage( var msg = await ms.updateMessage(
job.messageMap[recipient]!.id, job.messageMap[recipient]!.id,
mediaSize: stat.size,
errorType: noError, errorType: noError,
encryptionScheme: encryption != null
? SFSEncryptionType.aes256GcmNoPadding.toNamespace()
: null,
key: encryption != null ? base64Encode(encryption.key) : null,
iv: encryption != null ? base64Encode(encryption.iv) : null,
isUploading: false, isUploading: false,
srcUrl: slot.getUrl, fileMetadata: metadata,
); );
// TODO(Unknown): Maybe batch those two together? // TODO(Unknown): Maybe batch those two together?
final oldSid = msg.sid; final oldSid = msg.sid;
@@ -257,29 +337,6 @@ class HttpFileTransferService {
); );
sendEvent(MessageUpdatedEvent(message: msg)); sendEvent(MessageUpdatedEvent(message: msg));
StatelessFileSharingSource source;
final plaintextHashes = <String, String>{};
if (encryption != null) {
source = StatelessFileSharingEncryptedSource(
SFSEncryptionType.aes256GcmNoPadding,
encryption.key,
encryption.iv,
encryption.ciphertextHashes,
StatelessFileSharingUrlSource(slot.getUrl),
);
plaintextHashes.addAll(encryption.plaintextHashes);
} else {
source = StatelessFileSharingUrlSource(slot.getUrl);
try {
plaintextHashes[hashSha256] = await GetIt.I
.get<CryptographyService>()
.hashFile(job.path, HashFunction.sha256);
} catch (ex) {
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
}
}
// Send the message to the recipient // Send the message to the recipient
conn.getManagerById<MessageManager>(messageManager)!.sendMessage( conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails( MessageDetails(
@@ -292,7 +349,7 @@ class HttpFileTransferService {
FileMetadataData( FileMetadataData(
mediaType: job.mime, mediaType: job.mime,
size: stat.size, size: stat.size,
name: pathlib.basename(job.path), name: filename,
thumbnails: job.thumbnails, thumbnails: job.thumbnails,
hashes: plaintextHashes, hashes: plaintextHashes,
), ),
@@ -305,15 +362,14 @@ class HttpFileTransferService {
_log.finest( _log.finest(
'Sent message with file upload for ${job.path} to $recipient', 'Sent message with file upload for ${job.path} to $recipient',
); );
}
final isMultiMedia = (job.mime?.startsWith('image/') ?? false) || // Remove the old metadata only here because we would otherwise violate a foreign key
(job.mime?.startsWith('video/') ?? false); // constraint.
if (isMultiMedia) { if (metadataWrapper.retrieved) {
_log.finest( await GetIt.I.get<FilesService>().removeFileMetadata(
'File appears to be either an image or a video. Copying it to the correct directory...', job.metadataId,
); );
unawaited(_copyFile(job));
}
} }
} }
@@ -351,8 +407,10 @@ class HttpFileTransferService {
/// Actually attempt to download the file described by the job [job]. /// Actually attempt to download the file described by the job [job].
Future<void> _performFileDownload(FileDownloadJob job) async { Future<void> _performFileDownload(FileDownloadJob job) async {
final filename = job.location.filename; final filename = job.location.filename;
final downloadedPath = final downloadedPath = await computeCachedPathForFile(
await getDownloadPath(filename, job.conversationJid, job.mimeGuess); job.location.filename,
job.location.plaintextHashes,
);
var downloadPath = downloadedPath; var downloadPath = downloadedPath;
if (job.location.key != null && job.location.iv != null) { if (job.location.key != null && job.location.iv != null) {
@@ -361,15 +419,18 @@ class HttpFileTransferService {
downloadPath = pathlib.join(tempDir.path, filename); downloadPath = pathlib.join(tempDir.path, filename);
} }
// TODO(Unknown): Maybe try other URLs?
final downloadUrl = job.location.urls.first;
_log.finest( _log.finest(
'Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)', 'Downloading $downloadUrl as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)',
); );
int? downloadStatusCode; int? downloadStatusCode;
var integrityCheckPassed = true;
try { try {
_log.finest('Beginning download...'); _log.finest('Beginning download...');
downloadStatusCode = await client.downloadFile( downloadStatusCode = await client.downloadFile(
Uri.parse(job.location.url), Uri.parse(downloadUrl),
downloadPath, downloadPath,
(total, current) { (total, current) {
final progress = current.toDouble() / total.toDouble(); final progress = current.toDouble() / total.toDouble();
@@ -388,18 +449,15 @@ class HttpFileTransferService {
if (!isRequestOkay(downloadStatusCode)) { if (!isRequestOkay(downloadStatusCode)) {
_log.warning( _log.warning(
'HTTP GET of ${job.location.url} returned $downloadStatusCode', 'HTTP GET of $downloadUrl returned $downloadStatusCode',
); );
await _fileDownloadFailed(job, fileDownloadFailedError); await _fileDownloadFailed(job, fileDownloadFailedError);
return; return;
} }
var integrityCheckPassed = true;
final conv = (await GetIt.I
.get<ConversationService>()
.getConversationByJid(job.conversationJid))!;
final decryptionKeysAvailable = final decryptionKeysAvailable =
job.location.key != null && job.location.iv != null; job.location.key != null && job.location.iv != null;
final crypto = GetIt.I.get<CryptographyService>();
if (decryptionKeysAvailable) { if (decryptionKeysAvailable) {
// The file was downloaded and is now being decrypted // The file was downloaded and is now being decrypted
sendEvent( sendEvent(
@@ -409,15 +467,15 @@ class HttpFileTransferService {
); );
try { try {
final result = await GetIt.I.get<CryptographyService>().decryptFile( final result = await crypto.decryptFile(
downloadPath, downloadPath,
downloadedPath, downloadedPath,
encryptionTypeFromNamespace(job.location.encryptionScheme!), SFSEncryptionType.fromNamespace(job.location.encryptionScheme!),
job.location.key!, job.location.key!,
job.location.iv!, job.location.iv!,
job.location.plaintextHashes ?? {}, job.location.plaintextHashes ?? {},
job.location.ciphertextHashes ?? {}, job.location.ciphertextHashes ?? {},
); );
if (!result.decryptionOkay) { if (!result.decryptionOkay) {
_log.warning('Failed to decrypt $downloadPath'); _log.warning('Failed to decrypt $downloadPath');
@@ -437,6 +495,28 @@ class HttpFileTransferService {
unawaited( unawaited(
Directory(pathlib.dirname(downloadPath)).delete(recursive: true), Directory(pathlib.dirname(downloadPath)).delete(recursive: true),
); );
} else if (job.location.plaintextHashes?.isNotEmpty ?? false) {
// Verify only the plaintext hash
// TODO(Unknown): Allow verification of other hash functions
if (job.location.plaintextHashes![HashFunction.sha256] != null) {
final hash = await crypto.hashFile(
downloadPath,
HashFunction.sha256,
);
integrityCheckPassed =
hash == job.location.plaintextHashes![HashFunction.sha256];
} else if (job.location.plaintextHashes![HashFunction.sha512] != null) {
final hash = await crypto.hashFile(
downloadPath,
HashFunction.sha512,
);
integrityCheckPassed =
hash == job.location.plaintextHashes![HashFunction.sha512];
} else {
_log.warning(
'Could not verify file integrity as no accelerated hash function is available (${job.location.plaintextHashes!.keys})',
);
}
} }
// Check the MIME type // Check the MIME type
@@ -480,17 +560,37 @@ class HttpFileTransferService {
} }
} }
final fs = GetIt.I.get<FilesService>();
final metadata = await fs.updateFileMetadata(
job.metadataId,
path: downloadedPath,
size: File(downloadedPath).lengthSync(),
width: mediaWidth,
height: mediaHeight,
mimeType: mime,
);
// Only add the hash pointers if the file hashes match what was sent
if (job.location.plaintextHashes?.isNotEmpty ?? false) {
if (integrityCheckPassed) {
await fs.createMetadataHashEntries(
job.location.plaintextHashes!,
job.metadataId,
);
} else {
_log.warning('Integrity check failed for file');
}
}
final cs = GetIt.I.get<ConversationService>();
final conversation = (await cs.getConversationByJid(job.conversationJid))!;
final msg = await GetIt.I.get<MessageService>().updateMessage( final msg = await GetIt.I.get<MessageService>().updateMessage(
job.mId, job.mId,
mediaUrl: downloadedPath, fileMetadata: metadata,
mediaType: mime,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
mediaSize: File(downloadedPath).lengthSync(),
isFileUploadNotification: false, isFileUploadNotification: false,
warningType: warningType:
integrityCheckPassed ? null : warningFileIntegrityCheckFailed, integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
errorType: conv.encrypted && !decryptionKeysAvailable errorType: conversation.encrypted && !decryptionKeysAvailable
? messageChatEncryptedButFileNot ? messageChatEncryptedButFileNot
: null, : null,
isDownloading: false, isDownloading: false,
@@ -498,47 +598,21 @@ class HttpFileTransferService {
sendEvent(MessageUpdatedEvent(message: msg)); sendEvent(MessageUpdatedEvent(message: msg));
final sharedMedium = final updatedConversation = conversation.copyWith(
await GetIt.I.get<DatabaseService>().addSharedMediumFromData( lastMessage: conversation.lastMessage?.id == job.mId
downloadedPath, ? msg
msg.timestamp, : conversation.lastMessage,
conv.jid,
job.mId,
mime: mime,
);
final cs = GetIt.I.get<ConversationService>();
final updatedConv = await cs.createOrUpdateConversation(
conv.jid,
update: (c) {
return cs.updateConversation(
c.jid,
sharedMediaAmount: c.sharedMediaAmount + 1,
);
},
); );
final newConv = updatedConv!.copyWith( cs.setConversation(updatedConversation);
lastMessage: conv.lastMessage?.id == job.mId ? msg : conv.lastMessage,
sharedMedia: clampedListPrepend<SharedMedium>(
conv.sharedMedia,
sharedMedium,
8,
),
);
_log.finest(
'Amount of media before: ${conv.sharedMedia.length}, after: ${newConv.sharedMedia.length}',
);
GetIt.I.get<ConversationService>().setConversation(newConv);
// Show a notification // Show a notification
if (notification.shouldShowNotification(msg.conversationJid) && if (notification.shouldShowNotification(msg.conversationJid) &&
job.shouldShowNotification) { job.shouldShowNotification) {
_log.finest('Creating notification with bigPicture $downloadedPath'); _log.finest('Creating notification with bigPicture $downloadedPath');
await notification.showNotification(newConv, msg, ''); await notification.showNotification(updatedConversation, msg, '');
} }
sendEvent(ConversationUpdatedEvent(conversation: newConv)); sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
// Free the download resources for the next one // Free the download resources for the next one
await _pickNextDownloadTask(); await _pickNextDownloadTask();

View File

@@ -12,6 +12,7 @@ class FileUploadJob {
this.mime, this.mime,
this.encryptMap, this.encryptMap,
this.messageMap, this.messageMap,
this.metadataId,
this.thumbnails, this.thumbnails,
); );
final List<String> recipients; final List<String> recipients;
@@ -21,6 +22,7 @@ class FileUploadJob {
final Map<String, bool> encryptMap; final Map<String, bool> encryptMap;
// Recipient -> Message // Recipient -> Message
final Map<String, Message> messageMap; final Map<String, Message> messageMap;
final String metadataId;
final List<Thumbnail> thumbnails; final List<Thumbnail> thumbnails;
@override @override
@@ -31,7 +33,8 @@ class FileUploadJob {
messageMap == other.messageMap && messageMap == other.messageMap &&
mime == other.mime && mime == other.mime &&
thumbnails == other.thumbnails && thumbnails == other.thumbnails &&
encryptMap == other.encryptMap; encryptMap == other.encryptMap &&
metadataId == other.metadataId;
} }
@override @override
@@ -41,7 +44,8 @@ class FileUploadJob {
messageMap.hashCode ^ messageMap.hashCode ^
mime.hashCode ^ mime.hashCode ^
thumbnails.hashCode ^ thumbnails.hashCode ^
encryptMap.hashCode; encryptMap.hashCode ^
metadataId.hashCode;
} }
/// A job describing the upload of a file. /// A job describing the upload of a file.
@@ -50,12 +54,14 @@ class FileDownloadJob {
const FileDownloadJob( const FileDownloadJob(
this.location, this.location,
this.mId, this.mId,
this.metadataId,
this.conversationJid, this.conversationJid,
this.mimeGuess, { this.mimeGuess, {
this.shouldShowNotification = true, this.shouldShowNotification = true,
}); });
final MediaFileLocation location; final MediaFileLocation location;
final int mId; final int mId;
final String metadataId;
final String conversationJid; final String conversationJid;
final String? mimeGuess; final String? mimeGuess;
final bool shouldShowNotification; final bool shouldShowNotification;
@@ -65,6 +71,7 @@ class FileDownloadJob {
return other is FileDownloadJob && return other is FileDownloadJob &&
location == other.location && location == other.location &&
mId == other.mId && mId == other.mId &&
metadataId == other.metadataId &&
conversationJid == other.conversationJid && conversationJid == other.conversationJid &&
mimeGuess == other.mimeGuess && mimeGuess == other.mimeGuess &&
shouldShowNotification == other.shouldShowNotification; shouldShowNotification == other.shouldShowNotification;
@@ -74,6 +81,7 @@ class FileDownloadJob {
int get hashCode => int get hashCode =>
location.hashCode ^ location.hashCode ^
mId.hashCode ^ mId.hashCode ^
metadataId.hashCode ^
conversationJid.hashCode ^ conversationJid.hashCode ^
mimeGuess.hashCode ^ mimeGuess.hashCode ^
shouldShowNotification.hashCode; shouldShowNotification.hashCode;

View File

@@ -1,24 +1,27 @@
import 'dart:convert'; import 'dart:convert';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:moxxmpp/moxxmpp.dart';
@immutable @immutable
class MediaFileLocation { class MediaFileLocation {
const MediaFileLocation( const MediaFileLocation(
this.url, this.urls,
this.filename, this.filename,
this.encryptionScheme, this.encryptionScheme,
this.key, this.key,
this.iv, this.iv,
this.plaintextHashes, this.plaintextHashes,
this.ciphertextHashes, this.ciphertextHashes,
this.size,
); );
final String url; final List<String> urls;
final String filename; final String filename;
final String? encryptionScheme; final String? encryptionScheme;
final List<int>? key; final List<int>? key;
final List<int>? iv; final List<int>? iv;
final Map<String, String>? plaintextHashes; final Map<HashFunction, String>? plaintextHashes;
final Map<String, String>? ciphertextHashes; final Map<HashFunction, String>? ciphertextHashes;
final int? size;
String? get keyBase64 { String? get keyBase64 {
if (key != null) return base64Encode(key!); if (key != null) return base64Encode(key!);
@@ -34,22 +37,23 @@ class MediaFileLocation {
@override @override
int get hashCode => int get hashCode =>
url.hashCode ^ urls.hashCode ^
filename.hashCode ^ filename.hashCode ^
encryptionScheme.hashCode ^ encryptionScheme.hashCode ^
key.hashCode ^ key.hashCode ^
iv.hashCode ^ iv.hashCode ^
plaintextHashes.hashCode ^ plaintextHashes.hashCode ^
ciphertextHashes.hashCode; ciphertextHashes.hashCode ^
size.hashCode;
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
// TODO(PapaTutuWawa): Compare the Maps // TODO(PapaTutuWawa): Compare the Maps
return other is MediaFileLocation && return other is MediaFileLocation &&
url == other.url &&
filename == other.filename && filename == other.filename &&
encryptionScheme == other.encryptionScheme && encryptionScheme == other.encryptionScheme &&
key == other.key && key == other.key &&
iv == other.iv; iv == other.iv &&
size == other.size;
} }
} }

View File

@@ -1,17 +1,20 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/not_specified.dart'; import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/cache.dart'; import 'package:moxxyv2/shared/cache.dart';
import 'package:moxxyv2/shared/constants.dart'; import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
@@ -23,6 +26,97 @@ class MessageService {
LRUCache(conversationMessagePageCacheSize); LRUCache(conversationMessagePageCacheSize);
final Lock _cacheLock = Lock(); final Lock _cacheLock = Lock();
Future<Message?> getMessageById(
int id,
String conversationJid, {
bool queryReactionPreview = true,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final messagesRaw = await db.query(
messagesTable,
where: 'id = ? AND conversationJid = ?',
whereArgs: [id, conversationJid],
limit: 1,
);
if (messagesRaw.isEmpty) return null;
// TODO(PapaTutuWawa): Load the quoted message
final msg = messagesRaw.first;
// Load the file metadata, if available
FileMetadata? fm;
if (msg['file_metadata_id'] != null) {
final rawFm = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [msg['file_metadata_id']],
limit: 1,
))
.first;
fm = FileMetadata.fromDatabaseJson(rawFm);
}
return Message.fromDatabaseJson(
msg,
null,
fm,
queryReactionPreview
? await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(msg['id']! as int)
: [],
);
}
Future<Message?> getMessageByXmppId(
String id,
String conversationJid, {
bool includeOriginId = true,
bool queryReactionPreview = true,
}) async {
final db = GetIt.I.get<DatabaseService>().database;
final idQuery = includeOriginId ? '(sid = ? OR originId = ?)' : 'sid = ?';
final messagesRaw = await db.query(
messagesTable,
where: 'conversationJid = ? AND $idQuery',
whereArgs: [
conversationJid,
if (includeOriginId) id,
id,
],
limit: 1,
);
if (messagesRaw.isEmpty) return null;
// TODO(PapaTutuWawa): Load the quoted message
final msg = messagesRaw.first;
FileMetadata? fm;
if (msg['file_metadata_id'] != null) {
final rawFm = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [msg['file_metadata_id']],
limit: 1,
))
.first;
fm = FileMetadata.fromDatabaseJson(rawFm);
}
return Message.fromDatabaseJson(
msg,
null,
fm,
queryReactionPreview
? await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(msg['id']! as int)
: [],
);
}
/// Return a list of messages for [jid]. If [olderThan] is true, then all messages are older than [oldestTimestamp], if /// Return a list of messages for [jid]. If [olderThan] is true, then all messages are older than [oldestTimestamp], if
/// specified, or the oldest messages are returned if null. If [olderThan] is false, then message must be newer /// specified, or the oldest messages are returned if null. If [olderThan] is false, then message must be newer
/// than [oldestTimestamp], or the newest messages are returned if null. /// than [oldestTimestamp], or the newest messages are returned if null.
@@ -38,12 +132,108 @@ class MessageService {
if (result != null) return result; if (result != null) return result;
} }
final page = final db = GetIt.I.get<DatabaseService>().database;
await GetIt.I.get<DatabaseService>().getPaginatedMessagesForJid( final comparator = olderThan ? '<' : '>';
jid, final query = oldestTimestamp != null
olderThan, ? 'conversationJid = ? AND timestamp $comparator ?'
oldestTimestamp, : 'conversationJid = ?';
); final rawMessages = await db.rawQuery(
// LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id
'''
SELECT
msg.*,
quote.id AS quote_id,
quote.sender AS quote_sender,
quote.body AS quote_body,
quote.timestamp AS quote_timestamp,
quote.sid AS quote_sid,
quote.conversationJid AS quote_conversationJid,
quote.isFileUploadNotification AS quote_isFileUploadNotification,
quote.encrypted AS quote_encrypted,
quote.errorType AS quote_errorType,
quote.warningType AS quote_warningType,
quote.received AS quote_received,
quote.displayed AS quote_displayed,
quote.acked AS quote_acked,
quote.originId AS quote_originId,
quote.quote_id AS quote_quote_id,
quote.file_metadata_id AS quote_file_metadata_id,
quote.isDownloading AS quote_isDownloading,
quote.isUploading AS quote_isUploading,
quote.isRetracted AS quote_isRetracted,
quote.isEdited AS quote_isEdited,
quote.containsNoStore AS quote_containsNoStore,
quote.stickerPackId AS quote_stickerPackId,
quote.pseudoMessageType AS quote_pseudoMessageType,
quote.pseudoMessageData AS quote_pseudoMessageData,
fm.id as fm_id,
fm.path as fm_path,
fm.sourceUrls as fm_sourceUrls,
fm.mimeType as fm_mimeType,
fm.thumbnailType as fm_thumbnailType,
fm.thumbnailData as fm_thumbnailData,
fm.width as fm_width,
fm.height as fm_height,
fm.plaintextHashes as fm_plaintextHashes,
fm.encryptionKey as fm_encryptionKey,
fm.encryptionIv as fm_encryptionIv,
fm.encryptionScheme as fm_encryptionScheme,
fm.cipherTextHashes as fm_cipherTextHashes,
fm.filename as fm_filename,
fm.size as fm_size
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $messagePaginationSize) AS msg
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id
LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id;
''',
[
jid,
if (oldestTimestamp != null) oldestTimestamp,
],
);
final page = List<Message>.empty(growable: true);
for (final m in rawMessages) {
if (m.isEmpty) {
continue;
}
Message? quotes;
if (m['quote_id'] != null) {
final rawQuote = getPrefixedSubMap(m, 'quote_');
FileMetadata? quoteFm;
if (rawQuote['file_metadata_id'] != null) {
final rawQuoteFm = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [rawQuote['file_metadata_id']],
limit: 1,
))
.first;
quoteFm = FileMetadata.fromDatabaseJson(rawQuoteFm);
}
quotes = Message.fromDatabaseJson(rawQuote, null, quoteFm, []);
}
FileMetadata? fm;
if (m['file_metadata_id'] != null) {
fm = FileMetadata.fromDatabaseJson(
getPrefixedSubMap(m, 'fm_'),
);
}
page.add(
Message.fromDatabaseJson(
m,
quotes,
fm,
await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(m['id']! as int),
),
);
}
if (olderThan && oldestTimestamp == null) { if (olderThan && oldestTimestamp == null) {
await _cacheLock.synchronized(() { await _cacheLock.synchronized(() {
@@ -57,79 +247,130 @@ class MessageService {
return page; return page;
} }
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file
/// metadata attached. This method bypasses the cache and does not load the message's
/// quoted message, if it exists.
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
String jid,
bool olderThan,
int? oldestTimestamp,
) async {
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final query = oldestTimestamp != null
? 'conversationJid = ? AND file_metadata_id IS NOT NULL AND timestamp $comparator ?'
: 'conversationJid = ? AND file_metadata_id IS NOT NULL';
final rawMessages = await db.rawQuery(
'''
SELECT
msg.*,
fm.id as fm_id,
fm.path as fm_path,
fm.sourceUrls as fm_sourceUrls,
fm.mimeType as fm_mimeType,
fm.thumbnailType as fm_thumbnailType,
fm.thumbnailData as fm_thumbnailData,
fm.width as fm_width,
fm.height as fm_height,
fm.plaintextHashes as fm_plaintextHashes,
fm.encryptionKey as fm_encryptionKey,
fm.encryptionIv as fm_encryptionIv,
fm.encryptionScheme as fm_encryptionScheme,
fm.cipherTextHashes as fm_cipherTextHashes,
fm.filename as fm_filename,
fm.size as fm_size
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $sharedMediaPaginationSize) AS msg
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id;
''',
[
jid,
if (oldestTimestamp != null) oldestTimestamp,
],
);
final page = List<Message>.empty(growable: true);
for (final m in rawMessages) {
if (m.isEmpty) {
continue;
}
page.add(
Message.fromDatabaseJson(
m,
null,
FileMetadata.fromDatabaseJson(
getPrefixedSubMap(m, 'fm_'),
),
await GetIt.I
.get<ReactionsService>()
.getPreviewReactionsForMessage(m['id']! as int),
),
);
}
return page;
}
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache. /// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
Future<Message> addMessageFromData( Future<Message> addMessageFromData(
String body, String body,
int timestamp, int timestamp,
String sender, String sender,
String conversationJid, String conversationJid,
bool isMedia,
String sid, String sid,
bool isFileUploadNotification, bool isFileUploadNotification,
bool encrypted, bool encrypted,
bool containsNoStore, { bool containsNoStore, {
String? srcUrl,
String? key,
String? iv,
String? encryptionScheme,
String? mediaUrl,
String? mediaType,
String? thumbnailData,
int? mediaWidth,
int? mediaHeight,
String? originId, String? originId,
String? quoteId, String? quoteId,
String? filename, FileMetadata? fileMetadata,
int? errorType, int? errorType,
int? warningType, int? warningType,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
bool isDownloading = false, bool isDownloading = false,
bool isUploading = false, bool isUploading = false,
int? mediaSize,
String? stickerPackId, String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType, int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData, Map<String, dynamic>? pseudoMessageData,
bool received = false, bool received = false,
bool displayed = false, bool displayed = false,
}) async { }) async {
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData( final db = GetIt.I.get<DatabaseService>().database;
body, var m = Message(
timestamp, sender,
sender, body,
conversationJid, timestamp,
isMedia, sid,
sid, -1,
isFileUploadNotification, conversationJid,
encrypted, isFileUploadNotification,
containsNoStore, encrypted,
srcUrl: srcUrl, containsNoStore,
key: key, errorType: errorType,
iv: iv, warningType: warningType,
encryptionScheme: encryptionScheme, fileMetadata: fileMetadata,
mediaUrl: mediaUrl, received: received,
mediaType: mediaType, displayed: displayed,
thumbnailData: thumbnailData, acked: false,
mediaWidth: mediaWidth, originId: originId,
mediaHeight: mediaHeight, isUploading: isUploading,
originId: originId, isDownloading: isDownloading,
quoteId: quoteId, stickerPackId: stickerPackId,
filename: filename, pseudoMessageType: pseudoMessageType,
errorType: errorType, pseudoMessageData: pseudoMessageData,
warningType: warningType, );
plaintextHashes: plaintextHashes,
ciphertextHashes: ciphertextHashes, if (quoteId != null) {
isUploading: isUploading, final quotes = await getMessageByXmppId(quoteId, conversationJid);
isDownloading: isDownloading, if (quotes == null) {
mediaSize: mediaSize, _log.warning('Failed to add quote for message with id $quoteId');
stickerPackId: stickerPackId, } else {
stickerHashKey: stickerHashKey, m = m.copyWith(quotes: quotes);
pseudoMessageType: pseudoMessageType, }
pseudoMessageData: pseudoMessageData, }
received: received,
displayed: displayed, m = m.copyWith(
); id: await db.insert(messagesTable, m.toDatabaseJson()),
);
await _cacheLock.synchronized(() { await _cacheLock.synchronized(() {
final cachedList = _messageCache.getValue(conversationJid); final cachedList = _messageCache.getValue(conversationJid);
@@ -138,101 +379,137 @@ class MessageService {
conversationJid, conversationJid,
clampedListPrepend( clampedListPrepend(
cachedList, cachedList,
msg, m,
messagePaginationSize, messagePaginationSize,
), ),
); );
} }
}); });
return msg; return m;
} }
Future<Message?> getMessageByStanzaId( Future<Message?> getMessageByStanzaId(
String conversationJid, String conversationJid,
String stanzaId, String stanzaId,
) async { ) async {
return GetIt.I.get<DatabaseService>().getMessageByXmppId( return getMessageByXmppId(
stanzaId, stanzaId,
conversationJid, conversationJid,
includeOriginId: false, includeOriginId: false,
); );
} }
Future<Message?> getMessageByStanzaOrOriginId( Future<Message?> getMessageByStanzaOrOriginId(
String conversationJid, String conversationJid,
String id, String id,
) async { ) async {
return GetIt.I.get<DatabaseService>().getMessageByXmppId( return getMessageByXmppId(
id, id,
conversationJid, conversationJid,
); );
}
Future<Message?> getMessageById(String conversationJid, int id) async {
return GetIt.I.get<DatabaseService>().getMessageById(
id,
conversationJid,
);
} }
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache /// Wrapper around [DatabaseService]'s updateMessage that updates the cache
Future<Message> updateMessage( Future<Message> updateMessage(
int id, { int id, {
Object? body = notSpecified, Object? body = notSpecified,
Object? mediaUrl = notSpecified,
Object? mediaType = notSpecified,
bool? isMedia,
bool? received, bool? received,
bool? displayed, bool? displayed,
bool? acked, bool? acked,
Object? fileMetadata = notSpecified,
Object? errorType = notSpecified, Object? errorType = notSpecified,
Object? warningType = notSpecified, Object? warningType = notSpecified,
bool? isFileUploadNotification, bool? isFileUploadNotification,
Object? srcUrl = notSpecified,
Object? key = notSpecified,
Object? iv = notSpecified,
Object? encryptionScheme = notSpecified,
Object? mediaWidth = notSpecified,
Object? mediaHeight = notSpecified,
Object? mediaSize = notSpecified,
bool? isUploading, bool? isUploading,
bool? isDownloading, bool? isDownloading,
Object? originId = notSpecified, Object? originId = notSpecified,
Object? sid = notSpecified, Object? sid = notSpecified,
Object? thumbnailData = notSpecified,
bool? isRetracted, bool? isRetracted,
bool? isEdited, bool? isEdited,
Object? reactions = notSpecified,
}) async { }) async {
final msg = await GetIt.I.get<DatabaseService>().updateMessage( final db = GetIt.I.get<DatabaseService>().database;
id, final m = <String, dynamic>{};
body: body,
mediaUrl: mediaUrl, if (body != notSpecified) {
mediaType: mediaType, m['body'] = body as String?;
received: received, }
displayed: displayed, if (received != null) {
acked: acked, m['received'] = boolToInt(received);
errorType: errorType, }
warningType: warningType, if (displayed != null) {
isFileUploadNotification: isFileUploadNotification, m['displayed'] = boolToInt(displayed);
srcUrl: srcUrl, }
key: key, if (acked != null) {
iv: iv, m['acked'] = boolToInt(acked);
encryptionScheme: encryptionScheme, }
mediaWidth: mediaWidth, if (errorType != notSpecified) {
mediaHeight: mediaHeight, m['errorType'] = errorType as int?;
mediaSize: mediaSize, }
isUploading: isUploading, if (warningType != notSpecified) {
isDownloading: isDownloading, m['warningType'] = warningType as int?;
originId: originId, }
sid: sid, if (isFileUploadNotification != null) {
isRetracted: isRetracted, m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
isMedia: isMedia, }
thumbnailData: thumbnailData, if (isDownloading != null) {
isEdited: isEdited, m['isDownloading'] = boolToInt(isDownloading);
reactions: reactions, }
); if (isUploading != null) {
m['isUploading'] = boolToInt(isUploading);
}
if (sid != notSpecified) {
m['sid'] = sid as String?;
}
if (originId != notSpecified) {
m['originId'] = originId as String?;
}
if (isRetracted != null) {
m['isRetracted'] = boolToInt(isRetracted);
}
if (fileMetadata != notSpecified) {
m['file_metadata_id'] = (fileMetadata as FileMetadata?)?.id;
}
if (isEdited != null) {
m['isEdited'] = boolToInt(isEdited);
}
final updatedMessage = await db.updateAndReturn(
messagesTable,
m,
where: 'id = ?',
whereArgs: [id],
);
Message? quotes;
if (updatedMessage['quote_id'] != null) {
quotes = await getMessageById(
updatedMessage['quote_id']! as int,
updatedMessage['conversationJid']! as String,
queryReactionPreview: false,
);
}
FileMetadata? metadata;
if (fileMetadata != notSpecified) {
metadata = fileMetadata as FileMetadata?;
} else if (updatedMessage['file_metadata_id'] != null) {
final metadataRaw = (await db.query(
fileMetadataTable,
where: 'id = ?',
whereArgs: [updatedMessage['file_metadata_id']],
limit: 1,
))
.first;
metadata = FileMetadata.fromDatabaseJson(metadataRaw);
}
final msg = Message.fromDatabaseJson(
updatedMessage,
quotes,
metadata,
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(id),
);
await _cacheLock.synchronized(() { await _cacheLock.synchronized(() {
final page = _messageCache.getValue(msg.conversationJid); final page = _messageCache.getValue(msg.conversationJid);
@@ -256,7 +533,6 @@ class MessageService {
/// Helper function that manages everything related to retracting a message. It /// Helper function that manages everything related to retracting a message. It
/// - Replaces all metadata of the message with null values and marks it as retracted /// - Replaces all metadata of the message with null values and marks it as retracted
/// - Modified the conversation, if the retracted message was the newest message /// - Modified the conversation, if the retracted message was the newest message
/// - Remove the SharedMedium from the database, if one referenced the retracted message
/// - Update the UI /// - Update the UI
/// ///
/// [conversationJid] is the bare JID of the conversation this message belongs to. /// [conversationJid] is the bare JID of the conversation this message belongs to.
@@ -271,10 +547,10 @@ class MessageService {
String bareSender, String bareSender,
bool selfRetract, bool selfRetract,
) async { ) async {
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId( final msg = await getMessageByXmppId(
originId, originId,
conversationJid, conversationJid,
); );
if (msg == null) { if (msg == null) {
_log.finest( _log.finest(
@@ -294,24 +570,13 @@ class MessageService {
} }
final isMedia = msg.isMedia; final isMedia = msg.isMedia;
final mediaUrl = msg.mediaUrl;
final retractedMessage = await updateMessage( final retractedMessage = await updateMessage(
msg.id, msg.id,
isMedia: false,
mediaUrl: null,
mediaType: null,
warningType: null, warningType: null,
errorType: null, errorType: null,
srcUrl: null,
key: null,
iv: null,
encryptionScheme: null,
mediaWidth: null,
mediaHeight: null,
mediaSize: null,
isRetracted: true, isRetracted: true,
thumbnailData: null,
body: '', body: '',
fileMetadata: null,
); );
sendEvent(MessageUpdatedEvent(message: retractedMessage)); sendEvent(MessageUpdatedEvent(message: retractedMessage));
@@ -319,40 +584,23 @@ class MessageService {
final conversation = await cs.getConversationByJid(conversationJid); final conversation = await cs.getConversationByJid(conversationJid);
if (conversation != null) { if (conversation != null) {
if (conversation.lastMessage?.id == msg.id) { if (conversation.lastMessage?.id == msg.id) {
var newConversation = conversation.copyWith( final newConversation = conversation.copyWith(
lastMessage: retractedMessage, lastMessage: retractedMessage,
); );
if (isMedia) {
await GetIt.I
.get<DatabaseService>()
.removeSharedMediumByMessageId(msg.id);
// TODO(Unknown): Technically, we would have to then load 1 shared media
// item from the database to, if possible, fill the list
// back up to 8 items.
newConversation = newConversation.copyWith(
sharedMedia:
newConversation.sharedMedia.where((SharedMedium medium) {
return medium.messageId != msg.id;
}).toList(),
);
// Delete the file if we downloaded it
if (mediaUrl != null) {
final file = File(mediaUrl);
if (file.existsSync()) {
unawaited(file.delete());
}
}
}
cs.setConversation(newConversation); cs.setConversation(newConversation);
sendEvent( sendEvent(
ConversationUpdatedEvent( ConversationUpdatedEvent(
conversation: newConversation, conversation: newConversation,
), ),
); );
if (isMedia) {
// Remove the file
await GetIt.I.get<FilesService>().removeFileIfNotReferenced(
msg.fileMetadata!,
);
}
} }
} else { } else {
_log.warning( _log.warning(
@@ -360,4 +608,22 @@ class MessageService {
); );
} }
} }
Future<void> replaceMessageInCache(Message message) async {
await _cacheLock.synchronized(() {
final cachedList = _messageCache.getValue(message.conversationJid);
if (cachedList != null) {
_messageCache.replaceValue(
message.conversationJid,
cachedList.map((m) {
if (m.id == message.id) {
return message;
}
return m;
}).toList(),
);
}
});
}
} }

View File

@@ -100,7 +100,7 @@ class NotificationsService {
if (m.stickerPackId != null) { if (m.stickerPackId != null) {
body = t.messages.sticker; body = t.messages.sticker;
} else if (m.isMedia) { } else if (m.isMedia) {
body = mimeTypeToEmoji(m.mediaType); body = mimeTypeToEmoji(m.fileMetadata!.mimeType);
} else { } else {
body = m.body; body = m.body;
} }
@@ -126,7 +126,7 @@ class NotificationsService {
? NotificationLayout.BigPicture ? NotificationLayout.BigPicture
: NotificationLayout.Messaging, : NotificationLayout.Messaging,
category: NotificationCategory.Message, category: NotificationCategory.Message,
bigPicture: m.isThumbnailable ? 'file://${m.mediaUrl}' : null, bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
payload: <String, String>{ payload: <String, String>{
'conversationJid': c.jid, 'conversationJid': c.jid,
'sid': m.sid, 'sid': m.sid,

View File

@@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart'; import 'package:hex/hex.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp; import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/message.dart'; import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.dart'; import 'package:moxxyv2/service/moxxmpp/omemo.dart';
import 'package:moxxyv2/service/omemo/implementations.dart'; import 'package:moxxyv2/service/omemo/implementations.dart';
@@ -16,6 +19,7 @@ import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart' as model; import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
class OmemoDoubleRatchetWrapper { class OmemoDoubleRatchetWrapper {
@@ -40,21 +44,19 @@ class OmemoService {
final done = await _lock.synchronized(() => _initialized); final done = await _lock.synchronized(() => _initialized);
if (done) return; if (done) return;
final db = GetIt.I.get<DatabaseService>(); final device = await _loadOmemoDevice(jid);
final device = await db.loadOmemoDevice(jid);
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{}; final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
final deviceList = <String, List<int>>{}; final deviceList = <String, List<int>>{};
if (device == null) { if (device == null) {
_log.info('No OMEMO marker found. Generating OMEMO identity...'); _log.info('No OMEMO marker found. Generating OMEMO identity...');
} else { } else {
_log.info('OMEMO marker found. Restoring OMEMO state...'); _log.info('OMEMO marker found. Restoring OMEMO state...');
for (final ratchet for (final ratchet in await _loadRatchets()) {
in await GetIt.I.get<DatabaseService>().loadRatchets()) {
final key = RatchetMapKey(ratchet.jid, ratchet.id); final key = RatchetMapKey(ratchet.jid, ratchet.id);
ratchetMap[key] = ratchet.ratchet; ratchetMap[key] = ratchet.ratchet;
} }
deviceList.addAll(await db.loadOmemoDeviceList()); deviceList.addAll(await _loadOmemoDeviceList());
} }
final om = GetIt.I final om = GetIt.I
@@ -82,18 +84,18 @@ class OmemoService {
omemoManager.eventStream.listen((event) async { omemoManager.eventStream.listen((event) async {
if (event is RatchetModifiedEvent) { if (event is RatchetModifiedEvent) {
await GetIt.I.get<DatabaseService>().saveRatchet( await _saveRatchet(
OmemoDoubleRatchetWrapper( OmemoDoubleRatchetWrapper(
event.ratchet, event.ratchet,
event.deviceId, event.deviceId,
event.jid, event.jid,
), ),
); );
if (event.added) { if (event.added) {
// Cache the fingerprint // Cache the fingerprint
final fingerprint = await event.ratchet.getOmemoFingerprint(); final fingerprint = await event.ratchet.getOmemoFingerprint();
await GetIt.I.get<DatabaseService>().addFingerprintsToCache([ await _addFingerprintsToCache([
OmemoCacheTriple( OmemoCacheTriple(
event.jid, event.jid,
event.deviceId, event.deviceId,
@@ -143,7 +145,6 @@ class OmemoService {
DateTime.now().millisecondsSinceEpoch, DateTime.now().millisecondsSinceEpoch,
'', '',
jid, jid,
false,
'', '',
false, false,
false, false,
@@ -171,7 +172,7 @@ class OmemoService {
final oldId = await omemoManager.getDeviceId(); final oldId = await omemoManager.getDeviceId();
// Clear the database // Clear the database
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables(); await _emptyOmemoSessionTables();
// Regenerate the identity in the background // Regenerate the identity in the background
final device = await compute(generateNewIdentityImpl, jid); final device = await compute(generateNewIdentityImpl, jid);
@@ -228,11 +229,11 @@ class OmemoService {
} }
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async { Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap); await _saveOmemoDeviceList(deviceMap);
} }
Future<void> commitDevice(OmemoDevice device) async { Future<void> commitDevice(OmemoDevice device) async {
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device); await _saveOmemoDevice(device);
} }
/// Requests our device list and checks if the current device is in it. If not, then /// Requests our device list and checks if the current device is in it. If not, then
@@ -250,7 +251,7 @@ class OmemoService {
final device = await omemoManager.getDevice(); final device = await omemoManager.getDevice();
final bundlesRaw = await dm.discoItemsQuery( final bundlesRaw = await dm.discoItemsQuery(
bareJid.toString(), bareJid,
node: moxxmpp.omemoBundlesXmlns, node: moxxmpp.omemoBundlesXmlns,
); );
if (bundlesRaw.isType<moxxmpp.DiscoError>()) { if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
@@ -305,7 +306,7 @@ class OmemoService {
_fingerprintCache[bareJid] = map; _fingerprintCache[bareJid] = map;
// Cache them in the database // Cache them in the database
await GetIt.I.get<DatabaseService>().addFingerprintsToCache(items); await _addFingerprintsToCache(items);
} }
} }
@@ -313,9 +314,7 @@ class OmemoService {
final bareJid = jid.toBare().toString(); final bareJid = jid.toBare().toString();
if (!_fingerprintCache.containsKey(bareJid)) { if (!_fingerprintCache.containsKey(bareJid)) {
// First try to load it from the database // First try to load it from the database
final triples = await GetIt.I final triples = await _getFingerprintsFromCache(bareJid);
.get<DatabaseService>()
.getFingerprintsFromCache(bareJid);
if (triples.isEmpty) { if (triples.isEmpty) {
// We found no fingerprints in the database, so try to fetch them // We found no fingerprints in the database, so try to fetch them
await _fetchFingerprintsAndCache(jid); await _fetchFingerprintsAndCache(jid);
@@ -361,23 +360,22 @@ class OmemoService {
} }
Future<void> commitTrustManager(Map<String, dynamic> json) async { Future<void> commitTrustManager(Map<String, dynamic> json) async {
await GetIt.I.get<DatabaseService>().saveTrustCache( await _saveTrustCache(
json['trust']! as Map<String, int>, json['trust']! as Map<String, int>,
); );
await GetIt.I.get<DatabaseService>().saveTrustEnablementList( await _saveTrustEnablementList(
json['enable']! as Map<String, bool>, json['enable']! as Map<String, bool>,
); );
await GetIt.I.get<DatabaseService>().saveTrustDeviceList( await _saveTrustDeviceList(
json['devices']! as Map<String, List<int>>, json['devices']! as Map<String, List<int>>,
); );
} }
Future<MoxxyBTBVTrustManager> loadTrustManager() async { Future<MoxxyBTBVTrustManager> loadTrustManager() async {
final db = GetIt.I.get<DatabaseService>();
return MoxxyBTBVTrustManager( return MoxxyBTBVTrustManager(
await db.loadTrustCache(), await _loadTrustCache(),
await db.loadTrustEnablementList(), await _loadTrustEnablementList(),
await db.loadTrustDeviceList(), await _loadTrustDeviceList(),
); );
} }
@@ -455,4 +453,301 @@ class OmemoService {
omemoManager.onNewConnection(); omemoManager.onNewConnection();
} }
} }
/// Database methods
Future<List<OmemoDoubleRatchetWrapper>> _loadRatchets() async {
final results =
await GetIt.I.get<DatabaseService>().database.query(omemoRatchetsTable);
return results.map((ratchet) {
final json = jsonDecode(ratchet['mkskipped']! as String) as List<dynamic>;
final mkskipped = List<Map<String, dynamic>>.empty(growable: true);
for (final i in json) {
final element = i as Map<String, dynamic>;
mkskipped.add({
'key': element['key']! as String,
'public': element['public']! as String,
'n': element['n']! as int,
});
}
return OmemoDoubleRatchetWrapper(
OmemoDoubleRatchet.fromJson(
{
...ratchet,
'acknowledged': intToBool(ratchet['acknowledged']! as int),
'mkskipped': mkskipped,
},
),
ratchet['id']! as int,
ratchet['jid']! as String,
);
}).toList();
}
Future<void> _saveRatchet(OmemoDoubleRatchetWrapper ratchet) async {
final json = await ratchet.ratchet.toJson();
await GetIt.I.get<DatabaseService>().database.insert(
omemoRatchetsTable,
{
...json,
'mkskipped': jsonEncode(json['mkskipped']),
'acknowledged': boolToInt(json['acknowledged']! as bool),
'jid': ratchet.jid,
'id': ratchet.id,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<Map<RatchetMapKey, BTBVTrustState>> _loadTrustCache() async {
final entries = await GetIt.I
.get<DatabaseService>()
.database
.query(omemoTrustCacheTable);
final mapEntries =
entries.map<MapEntry<RatchetMapKey, BTBVTrustState>>((entry) {
// TODO(PapaTutuWawa): Expose this from omemo_dart
BTBVTrustState state;
final value = entry['trust']! as int;
if (value == 1) {
state = BTBVTrustState.notTrusted;
} else if (value == 2) {
state = BTBVTrustState.blindTrust;
} else if (value == 3) {
state = BTBVTrustState.verified;
} else {
state = BTBVTrustState.notTrusted;
}
return MapEntry(
RatchetMapKey.fromJsonKey(entry['key']! as String),
state,
);
});
return Map.fromEntries(mapEntries);
}
Future<void> _saveTrustCache(Map<String, int> cache) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch.delete(omemoTrustCacheTable);
for (final entry in cache.entries) {
batch.insert(
omemoTrustCacheTable,
{
'key': entry.key,
'trust': entry.value,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<Map<RatchetMapKey, bool>> _loadTrustEnablementList() async {
final entries = await GetIt.I
.get<DatabaseService>()
.database
.query(omemoTrustEnableListTable);
final mapEntries = entries.map<MapEntry<RatchetMapKey, bool>>((entry) {
return MapEntry(
RatchetMapKey.fromJsonKey(entry['key']! as String),
intToBool(entry['enabled']! as int),
);
});
return Map.fromEntries(mapEntries);
}
Future<void> _saveTrustEnablementList(Map<String, bool> list) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch.delete(omemoTrustEnableListTable);
for (final entry in list.entries) {
batch.insert(
omemoTrustEnableListTable,
{
'key': entry.key,
'enabled': boolToInt(entry.value),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<Map<String, List<int>>> _loadTrustDeviceList() async {
final entries = await GetIt.I
.get<DatabaseService>()
.database
.query(omemoTrustDeviceListTable);
final map = <String, List<int>>{};
for (final entry in entries) {
final key = entry['jid']! as String;
final device = entry['device']! as int;
if (map.containsKey(key)) {
map[key]!.add(device);
} else {
map[key] = [device];
}
}
return map;
}
Future<void> _saveTrustDeviceList(Map<String, List<int>> list) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch.delete(omemoTrustDeviceListTable);
for (final entry in list.entries) {
for (final device in entry.value) {
batch.insert(
omemoTrustDeviceListTable,
{
'jid': entry.key,
'device': device,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
await batch.commit();
}
Future<void> _saveOmemoDevice(OmemoDevice device) async {
await GetIt.I.get<DatabaseService>().database.insert(
omemoDeviceTable,
{
'jid': device.jid,
'id': device.id,
'data': jsonEncode(await device.toJson()),
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<OmemoDevice?> _loadOmemoDevice(String jid) async {
final data = await GetIt.I.get<DatabaseService>().database.query(
omemoDeviceTable,
where: 'jid = ?',
whereArgs: [jid],
limit: 1,
);
if (data.isEmpty) return null;
final deviceJson =
jsonDecode(data.first['data']! as String) as Map<String, dynamic>;
// NOTE: We need to do this because Dart otherwise complains about not being able
// to cast dynamic to List<int>.
final opks = List<Map<String, dynamic>>.empty(growable: true);
final opksIter = deviceJson['opks']! as List<dynamic>;
for (final tmpOpk in opksIter) {
final opk = tmpOpk as Map<String, dynamic>;
opks.add(<String, dynamic>{
'id': opk['id']! as int,
'public': opk['public']! as String,
'private': opk['private']! as String,
});
}
deviceJson['opks'] = opks;
return OmemoDevice.fromJson(deviceJson);
}
Future<Map<String, List<int>>> _loadOmemoDeviceList() async {
final list = await GetIt.I
.get<DatabaseService>()
.database
.query(omemoDeviceListTable);
final map = <String, List<int>>{};
for (final entry in list) {
final key = entry['jid']! as String;
final id = entry['id']! as int;
if (map.containsKey(key)) {
map[key]!.add(id);
} else {
map[key] = [id];
}
}
return map;
}
Future<void> _saveOmemoDeviceList(Map<String, List<int>> list) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch.delete(omemoDeviceListTable);
for (final entry in list.entries) {
for (final id in entry.value) {
batch.insert(
omemoDeviceListTable,
{
'jid': entry.key,
'id': id,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
}
await batch.commit();
}
Future<void> _emptyOmemoSessionTables() async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
// ignore: cascade_invocations
batch
..delete(omemoRatchetsTable)
..delete(omemoTrustCacheTable)
..delete(omemoTrustEnableListTable);
await batch.commit();
}
Future<void> _addFingerprintsToCache(List<OmemoCacheTriple> items) async {
final batch = GetIt.I.get<DatabaseService>().database.batch();
for (final item in items) {
batch.insert(
omemoFingerprintCache,
<String, dynamic>{
'jid': item.jid,
'id': item.deviceId,
'fingerprint': item.fingerprint,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
}
Future<List<OmemoCacheTriple>> _getFingerprintsFromCache(String jid) async {
final rawItems = await GetIt.I.get<DatabaseService>().database.query(
omemoFingerprintCache,
where: 'jid = ?',
whereArgs: [jid],
);
return rawItems.map((item) {
return OmemoCacheTriple(
jid,
item['id']! as int,
item['fingerprint']! as String,
);
}).toList();
}
} }

View File

@@ -1,12 +1,37 @@
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
class PreferencesService { class PreferencesService {
PreferencesState? _preferences; PreferencesState? _preferences;
Future<void> _loadPreferences() async { Future<void> _loadPreferences() async {
_preferences = await GetIt.I.get<DatabaseService>().getPreferences(); final db = GetIt.I.get<DatabaseService>().database;
final preferencesRaw = (await db.query(preferenceTable)).map((preference) {
switch (preference['type']! as int) {
case typeInt:
return {
...preference,
'value': stringToInt(preference['value']! as String),
};
case typeBool:
return {
...preference,
'value': stringToBool(preference['value']! as String),
};
case typeString:
default:
return preference;
}
}).toList();
final json = <String, dynamic>{};
for (final preference in preferencesRaw) {
json[preference['key']! as String] = preference['value'];
}
_preferences = PreferencesState.fromJson(json);
} }
Future<PreferencesState> getPreferences() async { Future<PreferencesState> getPreferences() async {
@@ -21,6 +46,38 @@ class PreferencesService {
if (_preferences == null) await _loadPreferences(); if (_preferences == null) await _loadPreferences();
_preferences = func(_preferences!); _preferences = func(_preferences!);
await GetIt.I.get<DatabaseService>().savePreferences(_preferences!);
final stateJson = _preferences!.toJson();
final preferences = stateJson.keys.map((key) {
int type;
String value;
if (stateJson[key] is int) {
type = typeInt;
value = intToString(stateJson[key]! as int);
} else if (stateJson[key] is bool) {
type = typeBool;
value = boolToString(stateJson[key]! as bool);
} else {
type = typeString;
value = stateJson[key]! as String;
}
return {
'key': key,
'type': type,
'value': value,
};
});
final batch = GetIt.I.get<DatabaseService>().database.batch();
for (final preference in preferences) {
batch.update(
preferenceTable,
preference,
where: 'key = ?',
whereArgs: [preference['key']],
);
}
await batch.commit();
} }
} }

203
lib/service/reactions.dart Normal file
View File

@@ -0,0 +1,203 @@
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
class ReactionWrapper {
const ReactionWrapper(this.emojis, this.modified);
final List<String> emojis;
final bool modified;
}
class ReactionsService {
final Logger _log = Logger('ReactionsService');
/// Query the database for 6 distinct emoji reactions associated with the message id
/// [id].
Future<List<String>> getPreviewReactionsForMessage(int id) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ?',
whereArgs: [id],
columns: ['emoji'],
distinct: true,
limit: 6,
);
return reactions.map((r) => r['emoji']! as String).toList();
}
Future<List<Reaction>> getReactionsForMessage(int id) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ?',
whereArgs: [id],
);
return reactions.map(Reaction.fromJson).toList();
}
Future<List<String>> getReactionsForMessageByJid(int id, String jid) async {
final reactions = await GetIt.I.get<DatabaseService>().database.query(
reactionsTable,
where: 'message_id = ? AND senderJid = ?',
whereArgs: [id, jid],
);
return reactions.map((r) => r['emoji']! as String).toList();
}
Future<int> _countReactions(int messageId, String emoji) async {
return GetIt.I.get<DatabaseService>().database.count(
reactionsTable,
'message_id = ? AND emoji = ?',
[messageId, emoji],
);
}
/// Adds a new reaction [emoji], if possible, to [messageId] and returns the
/// new message reaction preview.
Future<Message?> addNewReaction(
int messageId,
String conversationJid,
String emoji,
) async {
final ms = GetIt.I.get<MessageService>();
final msg = await ms.getMessageById(messageId, conversationJid);
if (msg == null) {
_log.warning('Failed to get message $messageId');
return null;
}
if (!msg.reactionsPreview.contains(emoji) &&
msg.reactionsPreview.length < 6) {
final newPreview = [
...msg.reactionsPreview,
emoji,
];
try {
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
await GetIt.I.get<DatabaseService>().database.insert(
reactionsTable,
Reaction(
messageId,
jid,
emoji,
).toJson(),
conflictAlgorithm: ConflictAlgorithm.fail,
);
final newMsg = msg.copyWith(
reactionsPreview: newPreview,
);
await ms.replaceMessageInCache(newMsg);
sendEvent(
MessageUpdatedEvent(
message: newMsg,
),
);
return newMsg;
} catch (ex) {
// The reaction already exists
return msg;
}
}
return msg;
}
Future<Message?> removeReaction(
int messageId,
String conversationJid,
String emoji,
) async {
final ms = GetIt.I.get<MessageService>();
final msg = await ms.getMessageById(messageId, conversationJid);
if (msg == null) {
_log.warning('Failed to get message $messageId');
return null;
}
await GetIt.I.get<DatabaseService>().database.delete(
reactionsTable,
where: 'message_id = ? AND emoji = ? AND senderJid = ?',
whereArgs: [
messageId,
emoji,
(await GetIt.I.get<XmppStateService>().getXmppState()).jid,
],
);
final count = await _countReactions(messageId, emoji);
if (count > 0) {
return msg;
}
final newPreview = List<String>.from(msg.reactionsPreview)..remove(emoji);
final newMsg = msg.copyWith(
reactionsPreview: newPreview,
);
await ms.replaceMessageInCache(newMsg);
sendEvent(
MessageUpdatedEvent(
message: newMsg,
),
);
return newMsg;
}
Future<void> processNewReactions(
Message msg,
String senderJid,
List<String> emojis,
) async {
// Get all reactions know for this message
final allReactions = await getReactionsForMessage(msg.id);
final userEmojis =
allReactions.where((r) => r.senderJid == senderJid).map((r) => r.emoji);
final removedReactions = userEmojis.where((e) => !emojis.contains(e));
final addedReactions = emojis.where((e) => !userEmojis.contains(e));
// Remove and add the new reactions
final db = GetIt.I.get<DatabaseService>().database;
for (final emoji in removedReactions) {
final rows = await db.delete(
reactionsTable,
where: 'message_id = ? AND senderJid = ? AND emoji = ?',
whereArgs: [msg.id, senderJid, emoji],
);
assert(rows == 1, 'Only one row should be removed');
}
for (final emoji in addedReactions) {
await db.insert(
reactionsTable,
Reaction(
msg.id,
senderJid,
emoji,
).toJson(),
);
}
final newMessage = msg.copyWith(
reactionsPreview: await getPreviewReactionsForMessage(msg.id),
);
await GetIt.I.get<MessageService>().replaceMessageInCache(
newMessage,
);
sendEvent(MessageUpdatedEvent(message: newMessage));
}
}

View File

@@ -3,7 +3,9 @@ import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/contacts.dart'; import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/not_specified.dart'; import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/subscription.dart'; import 'package:moxxyv2/service/subscription.dart';
@@ -42,19 +44,28 @@ class RosterService {
String? contactDisplayName, { String? contactDisplayName, {
List<String> groups = const [], List<String> groups = const [],
}) async { }) async {
final item = await GetIt.I.get<DatabaseService>().addRosterItemFromData( // TODO(PapaTutuWawa): Handle groups
avatarUrl, final i = RosterItem(
avatarHash, -1,
jid, avatarUrl,
title, avatarHash,
subscription, jid,
ask, title,
pseudoRosterItem, subscription,
contactId, ask,
contactAvatarPath, pseudoRosterItem,
contactDisplayName, <String>[],
groups: groups, contactId: contactId,
); contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
);
final item = i.copyWith(
id: await GetIt.I
.get<DatabaseService>()
.database
.insert(rosterTable, i.toDatabaseJson()),
);
// Update the cache // Update the cache
_rosterCache![item.jid] = item; _rosterCache![item.jid] = item;
@@ -76,19 +87,49 @@ class RosterService {
Object? contactAvatarPath = notSpecified, Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified, Object? contactDisplayName = notSpecified,
}) async { }) async {
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem( final i = <String, dynamic>{};
id,
avatarUrl: avatarUrl, if (avatarUrl != null) {
avatarHash: avatarHash, i['avatarUrl'] = avatarUrl;
title: title, }
subscription: subscription, if (avatarHash != null) {
ask: ask, i['avatarHash'] = avatarHash;
pseudoRosterItem: pseudoRosterItem, }
groups: groups, if (title != null) {
contactId: contactId, i['title'] = title;
contactAvatarPath: contactAvatarPath, }
contactDisplayName: contactDisplayName, /*
); if (groups != null) {
i.groups = groups;
}
*/
if (subscription != null) {
i['subscription'] = subscription;
}
if (ask != null) {
i['ask'] = ask;
}
if (contactId != notSpecified) {
i['contactId'] = contactId as String?;
}
if (contactAvatarPath != notSpecified) {
i['contactAvatarPath'] = contactAvatarPath as String?;
}
if (contactDisplayName != notSpecified) {
i['contactDisplayName'] = contactDisplayName as String?;
}
if (pseudoRosterItem != notSpecified) {
i['pseudoRosterItem'] = boolToInt(pseudoRosterItem as bool);
}
final result =
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
rosterTable,
i,
where: 'id = ?',
whereArgs: [id],
);
final newItem = RosterItem.fromDatabaseJson(result);
// Update cache // Update cache
_rosterCache![newItem.jid] = newItem; _rosterCache![newItem.jid] = newItem;
@@ -96,10 +137,14 @@ class RosterService {
return newItem; return newItem;
} }
/// Wrapper around [DatabaseService]'s removeRosterItem. /// Removes a roster item from the database and cache
Future<void> removeRosterItem(int id) async { Future<void> removeRosterItem(int id) async {
// NOTE: This call ensures that _rosterCache != null // NOTE: This call ensures that _rosterCache != null
await GetIt.I.get<DatabaseService>().removeRosterItem(id); await GetIt.I.get<DatabaseService>().database.delete(
rosterTable,
where: 'id = ?',
whereArgs: [id],
);
assert(_rosterCache != null, '_rosterCache must be non-null'); assert(_rosterCache != null, '_rosterCache must be non-null');
/// Update cache /// Update cache
@@ -136,14 +181,16 @@ class RosterService {
/// Load the roster from the database. This function is guarded against loading the /// Load the roster from the database. This function is guarded against loading the
/// roster multiple times and thus creating too many "RosterDiff" actions. /// roster multiple times and thus creating too many "RosterDiff" actions.
Future<List<RosterItem>> loadRosterFromDatabase() async { Future<List<RosterItem>> loadRosterFromDatabase() async {
final items = await GetIt.I.get<DatabaseService>().loadRosterItems(); final itemsRaw =
await GetIt.I.get<DatabaseService>().database.query(rosterTable);
final items = itemsRaw.map(RosterItem.fromDatabaseJson);
_rosterCache = <String, RosterItem>{}; _rosterCache = <String, RosterItem>{};
for (final item in items) { for (final item in items) {
_rosterCache![item.jid] = item; _rosterCache![item.jid] = item;
} }
return items; return items.toList();
} }
/// Attempts to add an item to the roster by first performing the roster set /// Attempts to add an item to the roster by first performing the roster set
@@ -169,6 +216,7 @@ class RosterService {
await css.getProfilePicturePathForJid(jid), await css.getProfilePicturePathForJid(jid),
await css.getContactDisplayName(contactId), await css.getContactDisplayName(contactId),
); );
final result = await GetIt.I final result = await GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getRosterManager()! .getRosterManager()!

View File

@@ -18,6 +18,7 @@ import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/cryptography/cryptography.dart'; import 'package:moxxyv2/service/cryptography/cryptography.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/events.dart'; import 'package:moxxyv2/service/events.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart'; import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/language.dart'; import 'package:moxxyv2/service/language.dart';
import 'package:moxxyv2/service/message.dart'; import 'package:moxxyv2/service/message.dart';
@@ -29,6 +30,7 @@ import 'package:moxxyv2/service/moxxmpp/stream.dart';
import 'package:moxxyv2/service/notifications.dart'; import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart'; import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/stickers.dart'; import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/subscription.dart'; import 'package:moxxyv2/service/subscription.dart';
@@ -176,6 +178,8 @@ Future<void> entrypoint() async {
GetIt.I.registerSingleton<SubscriptionRequestService>( GetIt.I.registerSingleton<SubscriptionRequestService>(
SubscriptionRequestService(), SubscriptionRequestService(),
); );
GetIt.I.registerSingleton<FilesService>(FilesService());
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
final xmpp = XmppService(); final xmpp = XmppService();
GetIt.I.registerSingleton<XmppService>(xmpp); GetIt.I.registerSingleton<XmppService>(xmpp);

View File

@@ -1,20 +1,24 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'dart:ui';
import 'package:archive/archive.dart'; import 'package:archive/archive.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp; import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/httpfiletransfer/client.dart'; import 'package:moxxyv2/service/httpfiletransfer/client.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart'; import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/sticker.dart'; import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart'; import 'package:moxxyv2/shared/models/sticker_pack.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@@ -26,31 +30,69 @@ class StickersService {
Future<StickerPack?> getStickerPackById(String id) async { Future<StickerPack?> getStickerPackById(String id) async {
if (_stickerPacks.containsKey(id)) return _stickerPacks[id]; if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
final pack = await GetIt.I.get<DatabaseService>().getStickerPackById(id); final db = GetIt.I.get<DatabaseService>().database;
if (pack == null) return null; final rawPack = await db.query(
stickerPacksTable,
_stickerPacks[id] = pack; where: 'id = ?',
return _stickerPacks[id]; whereArgs: [id],
} limit: 1,
Future<Sticker?> getStickerByHashKey(String packId, String hashKey) async {
final pack = await getStickerPackById(packId);
if (pack == null) return null;
return firstWhereOrNull<Sticker>(
pack.stickers,
(sticker) => sticker.hashKey == hashKey,
); );
if (rawPack.isEmpty) return null;
final rawStickers = await db.rawQuery(
'''
SELECT
sticker.*,
fm.id AS fm_id,
fm.path AS fm_path,
fm.sourceUrls AS fm_sourceUrls,
fm.mimeType AS fm_mimeType,
fm.thumbnailType AS fm_thumbnailType,
fm.thumbnailData AS fm_thumbnailData,
fm.width AS fm_width,
fm.height AS fm_height,
fm.plaintextHashes AS fm_plaintextHashes,
fm.encryptionKey AS fm_encryptionKey,
fm.encryptionIv AS fm_encryptionIv,
fm.encryptionScheme AS fm_encryptionScheme,
fm.cipherTextHashes AS fm_cipherTextHashes,
fm.filename AS fm_filename,
fm.size AS fm_size
FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
JOIN $fileMetadataTable fm ON sticker.file_metadata_id = fm.id;
''',
[id],
);
_stickerPacks[id] = StickerPack.fromDatabaseJson(
rawPack.first,
rawStickers.map((sticker) {
return Sticker.fromDatabaseJson(
sticker,
FileMetadata.fromDatabaseJson(
getPrefixedSubMap(sticker, 'fm_'),
),
);
}).toList(),
);
return _stickerPacks[id]!;
} }
Future<List<StickerPack>> getStickerPacks() async { Future<List<StickerPack>> getStickerPacks() async {
if (_stickerPacks.isEmpty) { if (_stickerPacks.isEmpty) {
final packs = await GetIt.I.get<DatabaseService>().loadStickerPacks(); final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
for (final pack in packs) { stickerPacksTable,
_stickerPacks[pack.id] = pack; columns: ['id'],
);
for (final rawPack in rawPackIds) {
final id = rawPack['id']! as String;
await getStickerPackById(id);
} }
} }
_log.finest('Got ${_stickerPacks.length} sticker packs');
return _stickerPacks.values.toList(); return _stickerPacks.values.toList();
} }
@@ -59,21 +101,27 @@ class StickersService {
assert(pack != null, 'The sticker pack must exist'); assert(pack != null, 'The sticker pack must exist');
// Delete the files // Delete the files
final stickerPackPath = await getStickerPackPath( for (final sticker in pack!.stickers) {
pack!.hashAlgorithm, if (sticker.fileMetadata.path == null) {
pack.hashValue, continue;
); }
final stickerPackDir = Directory(stickerPackPath);
if (stickerPackDir.existsSync()) { await GetIt.I.get<FilesService>().updateFileMetadata(
unawaited( sticker.fileMetadata.id,
stickerPackDir.delete( path: null,
recursive: true, );
), final file = File(sticker.fileMetadata.path!);
); if (file.existsSync()) {
await file.delete();
}
} }
// Remove from the database // Remove from the database
await GetIt.I.get<DatabaseService>().removeStickerPackById(id); await GetIt.I.get<DatabaseService>().database.delete(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
);
// Remove from the cache // Remove from the cache
_stickerPacks.remove(id); _stickerPacks.remove(id);
@@ -107,16 +155,6 @@ class StickersService {
} }
} }
/// Returns the path to the sticker pack with hash algorithm [algo] and hash [hash].
/// Ensures that the directory exists before returning.
Future<String> _getStickerPackPath(String algo, String hash) async {
final stickerDirPath = await getStickerPackPath(algo, hash);
final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
return stickerDirPath;
}
Future<void> importFromPubSubWithEvent( Future<void> importFromPubSubWithEvent(
moxxmpp.JID jid, moxxmpp.JID jid,
String stickerPackId, String stickerPackId,
@@ -158,36 +196,96 @@ class StickersService {
return installFromPubSub(stickerPackRaw); return installFromPubSub(stickerPackRaw);
} }
Future<void> _addStickerPackFromData(StickerPack pack) async {
await GetIt.I.get<DatabaseService>().database.insert(
stickerPacksTable,
pack.toDatabaseJson(),
);
}
Future<Sticker> _addStickerFromData(
String id,
String stickerPackId,
String desc,
Map<String, String> suggests,
FileMetadata fileMetadata,
) async {
final s = Sticker(
id,
stickerPackId,
desc,
suggests,
fileMetadata,
);
await GetIt.I.get<DatabaseService>().database.insert(
stickersTable,
s.toDatabaseJson(),
);
return s;
}
Future<StickerPack?> installFromPubSub(StickerPack remotePack) async { Future<StickerPack?> installFromPubSub(StickerPack remotePack) async {
assert(!remotePack.local, 'Sticker pack must be remote'); assert(!remotePack.local, 'Sticker pack must be remote');
final stickerPackPath = await _getStickerPackPath(
remotePack.hashAlgorithm,
remotePack.hashValue,
);
var success = true; var success = true;
final stickers = List<Sticker>.from(remotePack.stickers); final stickers = List<Sticker>.from(remotePack.stickers);
for (var i = 0; i < stickers.length; i++) { for (var i = 0; i < stickers.length; i++) {
final sticker = stickers[i]; final sticker = stickers[i];
final stickerPath = p.join( final stickerPath = await computeCachedPathForFile(
stickerPackPath, sticker.fileMetadata.filename,
sticker.hashes.values.first, sticker.fileMetadata.plaintextHashes,
);
final downloadStatusCode = await downloadFile(
Uri.parse(sticker.urlSources.first),
stickerPath,
(_, __) {},
); );
if (!isRequestOkay(downloadStatusCode)) { // Get file metadata
_log.severe('Request not okay: $downloadStatusCode'); final fileMetadataRaw =
success = false; await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
break; MediaFileLocation(
sticker.fileMetadata.sourceUrls!,
p.basename(stickerPath),
null,
null,
null,
sticker.fileMetadata.plaintextHashes,
null,
sticker.fileMetadata.size,
),
sticker.fileMetadata.mimeType,
sticker.fileMetadata.size,
sticker.fileMetadata.width != null &&
sticker.fileMetadata.height != null
? Size(
sticker.fileMetadata.width!.toDouble(),
sticker.fileMetadata.height!.toDouble(),
)
: null,
// TODO(Unknown): Maybe consider the thumbnails one day
null,
null,
path: stickerPath,
);
if (!fileMetadataRaw.retrieved) {
final downloadStatusCode = await downloadFile(
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
stickerPath,
(_, __) {},
);
if (!isRequestOkay(downloadStatusCode)) {
_log.severe('Request not okay: $downloadStatusCode');
success = false;
break;
}
} }
stickers[i] = sticker.copyWith(
path: stickerPath, stickers[i] = await _addStickerFromData(
hashKey: getStickerHashKey(sticker.hashes), getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
remotePack.hashValue,
sticker.desc,
sticker.suggests,
fileMetadataRaw.fileMetadata,
); );
} }
@@ -197,27 +295,7 @@ class StickersService {
} }
// Add the sticker pack to the database // Add the sticker pack to the database
final db = GetIt.I.get<DatabaseService>(); await _addStickerPackFromData(remotePack);
await db.addStickerPackFromData(remotePack);
// Add the stickers to the database
final stickersDb = List<Sticker>.empty(growable: true);
for (final sticker in stickers) {
stickersDb.add(
await db.addStickerFromData(
sticker.mediaType,
sticker.desc,
sticker.size,
sticker.width,
sticker.height,
sticker.hashes,
sticker.urlSources,
sticker.path,
remotePack.hashValue,
sticker.suggests,
),
);
}
// Publish but don't block // Publish but don't block
unawaited( unawaited(
@@ -225,7 +303,7 @@ class StickersService {
); );
return remotePack.copyWith( return remotePack.copyWith(
stickers: stickersDb, stickers: stickers,
local: true, local: true,
); );
} }
@@ -299,8 +377,6 @@ class StickersService {
final stickerDir = Directory(stickerDirPath); final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true); if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
final db = GetIt.I.get<DatabaseService>();
// Create the sticker pack first // Create the sticker pack first
final stickerPack = StickerPack( final stickerPack = StickerPack(
pack.hashValue, pack.hashValue,
@@ -312,33 +388,65 @@ class StickersService {
pack.restricted, pack.restricted,
true, true,
); );
await db.addStickerPackFromData(stickerPack); await _addStickerPackFromData(stickerPack);
// Add all stickers // Add all stickers
final stickers = List<Sticker>.empty(growable: true); final stickers = List<Sticker>.empty(growable: true);
for (final sticker in pack.stickers) { for (final sticker in pack.stickers) {
final filename = sticker.metadata.name!; // Get the "path" to the sticker
final stickerFile = archive.findFile(filename)!; final stickerPath = await computeCachedPathForFile(
final stickerPath = p.join(stickerDirPath, filename); sticker.metadata.name!,
await File(stickerPath).writeAsBytes( sticker.metadata.hashes,
stickerFile.content as List<int>,
); );
// Get metadata
final urlSources = sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList();
final fileMetadataRaw = await GetIt.I
.get<FilesService>()
.createFileMetadataIfRequired(
MediaFileLocation(
urlSources,
p.basename(stickerPath),
null,
null,
null,
sticker.metadata.hashes,
null,
sticker.metadata.size,
),
sticker.metadata.mediaType,
sticker.metadata.size,
sticker.metadata.width != null && sticker.metadata.height != null
? Size(
sticker.metadata.width!.toDouble(),
sticker.metadata.height!.toDouble(),
)
: null,
// TODO(Unknown): Maybe consider the thumbnails one day
null,
null,
path: stickerPath,
);
// Only copy the sticker to storage if we don't already have it
if (!fileMetadataRaw.retrieved) {
final stickerFile = archive.findFile(sticker.metadata.name!)!;
await File(stickerPath).writeAsBytes(
stickerFile.content as List<int>,
);
}
stickers.add( stickers.add(
await db.addStickerFromData( await _addStickerFromData(
sticker.metadata.mediaType!, getStrongestHashFromMap(sticker.metadata.hashes) ??
sticker.metadata.desc!, DateTime.now().millisecondsSinceEpoch.toString(),
sticker.metadata.size!,
null,
null,
sticker.metadata.hashes,
sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((moxxmpp.StatelessFileSharingUrlSource source) => source.url)
.toList(),
stickerPath,
pack.hashValue, pack.hashValue,
sticker.metadata.desc!,
sticker.suggests, sticker.suggests,
fileMetadataRaw.fileMetadata,
), ),
); );
} }

View File

@@ -1,6 +1,8 @@
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.dart'; import 'package:synchronized/synchronized.dart';
class SubscriptionRequestService { class SubscriptionRequestService {
@@ -8,15 +10,18 @@ class SubscriptionRequestService {
final Lock _lock = Lock(); final Lock _lock = Lock();
DatabaseService get _db => GetIt.I.get<DatabaseService>();
/// Only load data from the database into /// Only load data from the database into
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet /// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
/// been loaded. /// been loaded.
Future<void> _loadSubscriptionRequestsIfNeeded() async { Future<void> _loadSubscriptionRequestsIfNeeded() async {
await _lock.synchronized(() async { await _lock.synchronized(() async {
_subscriptionRequests ??= List<String>.from( _subscriptionRequests ??= List<String>.from(
await _db.getSubscriptionRequests(), (await GetIt.I
.get<DatabaseService>()
.database
.query(subscriptionsTable))
.map((m) => m['jid']! as String)
.toList(),
); );
}); });
} }
@@ -33,7 +38,13 @@ class SubscriptionRequestService {
if (!_subscriptionRequests!.contains(jid)) { if (!_subscriptionRequests!.contains(jid)) {
_subscriptionRequests!.add(jid); _subscriptionRequests!.add(jid);
await _db.addSubscriptionRequest(jid); await GetIt.I.get<DatabaseService>().database.insert(
subscriptionsTable,
{
'jid': jid,
},
conflictAlgorithm: ConflictAlgorithm.ignore,
);
} }
}); });
} }
@@ -44,7 +55,11 @@ class SubscriptionRequestService {
await _lock.synchronized(() async { await _lock.synchronized(() async {
if (_subscriptionRequests!.contains(jid)) { if (_subscriptionRequests!.contains(jid)) {
_subscriptionRequests!.remove(jid); _subscriptionRequests!.remove(jid);
await _db.removeSubscriptionRequest(jid); await GetIt.I.get<DatabaseService>().database.delete(
subscriptionsTable,
where: 'jid = ?',
whereArgs: [jid],
);
} }
}); });
} }

View File

@@ -15,19 +15,20 @@ import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/connectivity_watcher.dart'; import 'package:moxxyv2/service/connectivity_watcher.dart';
import 'package:moxxyv2/service/contacts.dart'; import 'package:moxxyv2/service/contacts.dart';
import 'package:moxxyv2/service/conversation.dart'; import 'package:moxxyv2/service/conversation.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/helpers.dart'; import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart'; import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart'; import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart'; import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart'; import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/message.dart'; import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/notifications.dart'; import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart'; import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart'; import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart'; import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart'; import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/subscription.dart'; import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/service/xmpp_state.dart'; import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/error_types.dart'; import 'package:moxxyv2/shared/error_types.dart';
@@ -35,9 +36,8 @@ import 'package:moxxyv2/shared/eventhandler.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker; import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:omemo_dart/omemo_dart.dart'; import 'package:omemo_dart/omemo_dart.dart';
import 'package:path/path.dart' as pathlib; import 'package:path/path.dart' as pathlib;
@@ -47,6 +47,7 @@ class XmppService {
XmppService() { XmppService() {
_eventHandler.addMatchers([ _eventHandler.addMatchers([
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged), EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
EventTypeMatcher<StreamNegotiationsDoneEvent>(_onStreamNegotiationsDone),
EventTypeMatcher<ResourceBoundEvent>(_onResourceBound), EventTypeMatcher<ResourceBoundEvent>(_onResourceBound),
EventTypeMatcher<SubscriptionRequestReceivedEvent>( EventTypeMatcher<SubscriptionRequestReceivedEvent>(
_onSubscriptionRequestReceived, _onSubscriptionRequestReceived,
@@ -222,7 +223,6 @@ class XmppService {
timestamp, timestamp,
conn.connectionSettings.jid.toString(), conn.connectionSettings.jid.toString(),
recipient, recipient,
sticker != null,
sid, sid,
false, false,
c.type == ConversationType.note ? true : c.encrypted, c.type == ConversationType.note ? true : c.encrypted,
@@ -231,9 +231,7 @@ class XmppService {
originId: originId, originId: originId,
quoteId: quotedMessage?.sid, quoteId: quotedMessage?.sid,
stickerPackId: sticker?.stickerPackId, stickerPackId: sticker?.stickerPackId,
stickerHashKey: sticker?.hashKey, fileMetadata: sticker?.fileMetadata,
srcUrl: sticker?.urlSources.first,
mediaType: sticker?.mediaType,
received: c.type == ConversationType.note ? true : false, received: c.type == ConversationType.note ? true : false,
displayed: c.type == ConversationType.note ? true : false, displayed: c.type == ConversationType.note ? true : false,
); );
@@ -260,6 +258,7 @@ class XmppService {
} }
if (conversation?.type == ConversationType.chat) { if (conversation?.type == ConversationType.chat) {
final moxxmppSticker = sticker?.toMoxxmpp();
conn.getManagerById<MessageManager>(messageManager)!.sendMessage( conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails( MessageDetails(
to: recipient, to: recipient,
@@ -273,23 +272,12 @@ class XmppService {
chatState: chatState, chatState: chatState,
shouldEncrypt: conversation!.encrypted, shouldEncrypt: conversation!.encrypted,
stickerPackId: sticker?.stickerPackId, stickerPackId: sticker?.stickerPackId,
sfs: sticker == null sfs: moxxmppSticker != null
? null ? StatelessFileSharingData(
: StatelessFileSharingData( moxxmppSticker.metadata,
FileMetadataData( moxxmppSticker.sources,
mediaType: sticker.mediaType, )
width: sticker.width, : null,
height: sticker.height,
desc: sticker.desc,
size: sticker.size,
thumbnails: [],
hashes: sticker.hashes,
),
sticker.urlSources
// ignore: unnecessary_lambdas
.map((s) => StatelessFileSharingUrlSource(s))
.toList(),
),
setOOBFallbackBody: sticker != null ? false : true, setOOBFallbackBody: sticker != null ? false : true,
), ),
); );
@@ -301,50 +289,67 @@ class XmppService {
} }
} }
MediaFileLocation? _getMessageSrcUrl(MessageEvent event) { MediaFileLocation? _getEmbeddedFile(MessageEvent event) {
if (event.sfs != null) { if (event.sfs?.sources.isNotEmpty ?? false) {
final source = firstWhereOrNull( // final source = firstWhereOrNull(
event.sfs!.sources, // event.sfs!.sources,
(StatelessFileSharingSource source) { // (StatelessFileSharingSource source) {
return source is StatelessFileSharingUrlSource || // return source is StatelessFileSharingUrlSource ||
source is StatelessFileSharingEncryptedSource; // source is StatelessFileSharingEncryptedSource;
}, // },
); // );
final name = event.sfs?.metadata.name; final hasUrlSource = firstWhereOrNull(
if (source is StatelessFileSharingUrlSource) { event.sfs!.sources,
(src) => src is StatelessFileSharingUrlSource,
) !=
null;
final name = event.sfs!.metadata.name;
if (hasUrlSource) {
final sources = event.sfs!.sources
.whereType<StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList();
return MediaFileLocation( return MediaFileLocation(
source.url, sources,
name != null ? escapeFilename(name) : filenameFromUrl(source.url), name != null ? escapeFilename(name) : filenameFromUrl(sources.first),
null, null,
null, null,
null, null,
event.sfs?.metadata.hashes, event.sfs!.metadata.hashes,
null, null,
event.sfs!.metadata.size,
); );
} else { } else {
final esource = source! as StatelessFileSharingEncryptedSource; final encryptedSource = firstWhereOrNull(
event.sfs!.sources,
(src) => src is StatelessFileSharingEncryptedSource,
)! as StatelessFileSharingEncryptedSource;
return MediaFileLocation( return MediaFileLocation(
esource.source.url, [encryptedSource.source.url],
name != null name != null
? escapeFilename(name) ? escapeFilename(name)
: filenameFromUrl(esource.source.url), : filenameFromUrl(encryptedSource.source.url),
esource.encryption.toNamespace(), encryptedSource.encryption.toNamespace(),
esource.key, encryptedSource.key,
esource.iv, encryptedSource.iv,
event.sfs?.metadata.hashes, event.sfs?.metadata.hashes,
esource.hashes, encryptedSource.hashes,
event.sfs!.metadata.size,
); );
} }
} else if (event.oob != null) { } else if (event.oob != null) {
return MediaFileLocation( return MediaFileLocation(
event.oob!.url!, [event.oob!.url!],
filenameFromUrl(event.oob!.url!), filenameFromUrl(event.oob!.url!),
null, null,
null, null,
null, null,
null, null,
null, null,
null,
); );
} }
@@ -355,22 +360,25 @@ class XmppService {
final result = await GetIt.I final result = await GetIt.I
.get<XmppConnection>() .get<XmppConnection>()
.getDiscoManager()! .getDiscoManager()!
.discoInfoQuery(event.fromJid.toString()); .discoInfoQuery(event.fromJid);
if (result.isType<DiscoError>()) return; if (result.isType<DiscoError>()) return;
final info = result.get<DiscoInfo>(); final info = result.get<DiscoInfo>();
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) { if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
unawaited( unawaited(
GetIt.I.get<XmppConnection>().sendStanza( GetIt.I.get<XmppConnection>().sendStanza(
Stanza.message( StanzaDetails(
to: event.fromJid.toBare().toString(), Stanza.message(
type: event.type, to: event.fromJid.toBare().toString(),
children: [ type: event.type,
makeChatMarker( children: [
'received', makeChatMarker(
event.stanzaId.originId ?? event.sid, 'received',
) event.originId ?? event.sid,
], )
],
),
awaitable: false,
), ),
), ),
); );
@@ -378,14 +386,17 @@ class XmppService {
info.features.contains(deliveryXmlns)) { info.features.contains(deliveryXmlns)) {
unawaited( unawaited(
GetIt.I.get<XmppConnection>().sendStanza( GetIt.I.get<XmppConnection>().sendStanza(
Stanza.message( StanzaDetails(
to: event.fromJid.toBare().toString(), Stanza.message(
type: event.type, to: event.fromJid.toBare().toString(),
children: [ type: event.type,
makeMessageDeliveryResponse( children: [
event.stanzaId.originId ?? event.sid, makeMessageDeliveryResponse(
) event.originId ?? event.sid,
], )
],
),
awaitable: false,
), ),
), ),
); );
@@ -401,12 +412,15 @@ class XmppService {
unawaited( unawaited(
GetIt.I.get<XmppConnection>().sendStanza( GetIt.I.get<XmppConnection>().sendStanza(
Stanza.message( StanzaDetails(
to: to, Stanza.message(
type: 'chat', to: to,
children: [ type: 'chat',
makeChatMarker('displayed', sid), children: [
], makeChatMarker('displayed', sid),
],
),
awaitable: false,
), ),
), ),
); );
@@ -442,12 +456,15 @@ class XmppService {
_loginTriggeredFromUI = triggeredFromUI; _loginTriggeredFromUI = triggeredFromUI;
conn conn
..connectionSettings = settings ..connectionSettings = settings
..getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.resource = lastResource; ..getNegotiatorById<StreamManagementNegotiator>(
streamManagementNegotiator,
)!
.resource = lastResource;
unawaited( unawaited(
conn.connect( conn.connect(
waitForConnection: true, waitForConnection: true,
shouldReconnect: true, shouldReconnect: true,
), ),
); );
installEventHandlers(); installEventHandlers();
} }
@@ -463,40 +480,15 @@ class XmppService {
_loginTriggeredFromUI = triggeredFromUI; _loginTriggeredFromUI = triggeredFromUI;
conn conn
..connectionSettings = settings ..connectionSettings = settings
..getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.resource = lastResource; ..getNegotiatorById<StreamManagementNegotiator>(
streamManagementNegotiator,
)!
.resource = lastResource;
installEventHandlers(); installEventHandlers();
return conn.connect( return conn.connect(
waitForConnection: true, waitForConnection: true,
waitUntilLogin: true, waitUntilLogin: true,
); );
}
/// Wrapper function for creating shared media entries for the given paths.
/// [messages] is the mapping of "File path -> Recipient -> Message" required for
/// setting the single shared medium's message Id attribute.
/// [paths] is the list of paths to create shared media entries for.
/// [recipient] is the bare string JID that the messages will be sent to.
/// [conversationJid] is the JID of the conversation these shared media entries
/// belong to.
Future<List<SharedMedium>> _createSharedMedia(
Map<String, Map<String, Message>> messages,
List<String> paths,
String recipient,
String conversationJid,
) async {
final sharedMedia = List<SharedMedium>.empty(growable: true);
for (final path in paths) {
sharedMedia.add(
await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
path,
DateTime.now().millisecondsSinceEpoch,
conversationJid,
messages[path]![recipient]!.id,
mime: lookupMimeType(path),
),
);
}
return sharedMedia;
} }
Future<void> sendFiles(List<String> paths, List<String> recipients) async { Future<void> sendFiles(List<String> paths, List<String> recipients) async {
@@ -516,49 +508,67 @@ class XmppService {
final encrypt = <String, bool>{}; final encrypt = <String, bool>{};
// Recipient -> Last message Id // Recipient -> Last message Id
final lastMessages = <String, Message>{}; final lastMessages = <String, Message>{};
// Path -> Metadata Id
final metadataMap = <String, String>{};
// Create the messages and shared media entries // Create the messages and shared media entries
final conn = GetIt.I.get<XmppConnection>(); final conn = GetIt.I.get<XmppConnection>();
for (final path in paths) { for (final path in paths) {
final pathMime = lookupMimeType(path); final pathMime = lookupMimeType(path);
// TODO(Unknown): Do the same for videos
if (pathMime != null && pathMime.startsWith('image/')) {
final imageSize = await getImageSizeFromPath(path);
if (imageSize != null) {
dimensions[path] = Size(
imageSize.width,
imageSize.height,
);
} else {
_log.warning('Failed to get image dimensions for $path');
}
}
final metadata =
await GetIt.I.get<FilesService>().addFileMetadataFromData(
FileMetadata(
DateTime.now().millisecondsSinceEpoch.toString(),
path,
null,
pathMime,
File(path).lengthSync(),
null,
null,
dimensions[path]?.width.toInt(),
dimensions[path]?.height.toInt(),
null,
null,
null,
null,
null,
pathlib.basename(path),
),
);
metadataMap[path] = metadata.id;
for (final recipient in recipients) { for (final recipient in recipients) {
final conversation = await cs.getConversationByJid(recipient); final conversation = await cs.getConversationByJid(recipient);
encrypt[recipient] = encrypt[recipient] =
conversation?.encrypted ?? prefs.enableOmemoByDefault; conversation?.encrypted ?? prefs.enableOmemoByDefault;
// TODO(Unknown): Do the same for videos
if (pathMime != null && pathMime.startsWith('image/')) {
final imageSize = await getImageSizeFromPath(path);
if (imageSize != null) {
dimensions[path] = Size(
imageSize.width,
imageSize.height,
);
} else {
_log.warning('Failed to get image dimensions for $path');
}
}
final msg = await ms.addMessageFromData( final msg = await ms.addMessageFromData(
'', '',
DateTime.now().millisecondsSinceEpoch, DateTime.now().millisecondsSinceEpoch,
conn.connectionSettings.jid.toString(), conn.connectionSettings.jid.toString(),
recipient, recipient,
true,
conn.generateId(), conn.generateId(),
false,
conversation?.type == ConversationType.note conversation?.type == ConversationType.note
? true ? true
: encrypt[recipient]!, : encrypt[recipient]!,
// TODO(Unknown): Maybe make this depend on some setting // TODO(Unknown): Maybe make this depend on some setting
false, false,
mediaUrl: path, false,
mediaType: pathMime, fileMetadata: metadata,
originId: conn.generateId(), originId: conn.generateId(),
mediaWidth: dimensions[path]?.width.toInt(),
mediaHeight: dimensions[path]?.height.toInt(),
filename: pathlib.basename(path),
isUploading: isUploading:
conversation?.type != ConversationType.note ? true : false, conversation?.type != ConversationType.note ? true : false,
received: conversation?.type == ConversationType.note ? true : false, received: conversation?.type == ConversationType.note ? true : false,
@@ -578,11 +588,7 @@ class XmppService {
} }
} }
// Create the shared media entries
// Recipient -> [Shared Medium]
final sharedMediaMap = <String, List<SharedMedium>>{};
final rs = GetIt.I.get<RosterService>(); final rs = GetIt.I.get<RosterService>();
for (final recipient in recipients) { for (final recipient in recipients) {
await cs.createOrUpdateConversation( await cs.createOrUpdateConversation(
recipient, recipient,
@@ -590,7 +596,7 @@ class XmppService {
// Create // Create
final rosterItem = await rs.getRosterItemByJid(recipient); final rosterItem = await rs.getRosterItemByJid(recipient);
final contactId = await css.getContactIdForJid(recipient); final contactId = await css.getContactIdForJid(recipient);
var newConversation = await cs.addConversationFromData( final newConversation = await cs.addConversationFromData(
// TODO(Unknown): Should we use the JID parser? // TODO(Unknown): Should we use the JID parser?
rosterItem?.title ?? recipient.split('@').first, rosterItem?.title ?? recipient.split('@').first,
lastMessages[recipient], lastMessages[recipient],
@@ -602,22 +608,11 @@ class XmppService {
true, true,
prefs.defaultMuteState, prefs.defaultMuteState,
prefs.enableOmemoByDefault, prefs.enableOmemoByDefault,
paths.length,
contactId, contactId,
await css.getProfilePicturePathForJid(recipient), await css.getProfilePicturePathForJid(recipient),
await css.getContactDisplayName(contactId), await css.getContactDisplayName(contactId),
); );
final sharedMedia = await _createSharedMedia(
messages,
paths,
recipient,
newConversation.jid,
);
newConversation = newConversation.copyWith(
sharedMedia: sharedMedia.sublist(0, 8),
);
// Update the cache // Update the cache
cs.setConversation(newConversation); cs.setConversation(newConversation);
@@ -628,28 +623,11 @@ class XmppService {
}, },
update: (c) async { update: (c) async {
// Update // Update
var newConversation = await cs.updateConversation( final newConversation = await cs.updateConversation(
c.jid, c.jid,
lastMessage: lastMessages[recipient], lastMessage: lastMessages[recipient],
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch, lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
open: true, open: true,
sharedMediaAmount: c.sharedMediaAmount + paths.length,
);
sharedMediaMap[recipient] = await _createSharedMedia(
messages,
paths,
recipient,
// TODO(Unknown): Remove since recipient and c.jid are now the same
c.jid,
);
newConversation = newConversation.copyWith(
sharedMedia: clampedListPrependAll(
c.sharedMedia,
sharedMediaMap[recipient]!,
8,
),
); );
// Update the cache // Update the cache
@@ -710,6 +688,7 @@ class XmppService {
pathMime, pathMime,
encrypt, encrypt,
messages[path]!, messages[path]!,
metadataMap[path]!,
thumbnails[path] ?? [], thumbnails[path] ?? [],
), ),
); );
@@ -763,6 +742,74 @@ class XmppService {
} }
} }
Future<void> _onStreamNegotiationsDone(
StreamNegotiationsDoneEvent event, {
dynamic extra,
}) async {
final connection = GetIt.I.get<XmppConnection>();
// TODO(Unknown): Maybe have something better
final settings = connection.connectionSettings;
await GetIt.I.get<XmppStateService>().modifyXmppState(
(state) => state.copyWith(
jid: settings.jid.toString(),
password: settings.password,
),
);
_log.finest('Connection connected. Is resumed? ${event.resumed}');
unawaited(_initializeOmemoService(settings.jid.toString()));
if (!event.resumed) {
// Reset the blocking service's cache
GetIt.I.get<BlocklistService>().onNewConnection();
// Reset the OMEMO cache
GetIt.I.get<OmemoService>().onNewConnection();
// Enable carbons
final carbonsResult = await connection
.getManagerById<CarbonsManager>(carbonsManager)!
.enableCarbons();
if (!carbonsResult) {
_log.warning('Failed to enable carbons');
}
// In section 5 of XEP-0198 it says that a client should not request the roster
// in case of a stream resumption.
await connection
.getManagerById<RosterManager>(rosterManager)!
.requestRoster();
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
final roster = await GetIt.I.get<RosterService>().getRoster();
for (final item in roster) {
await GetIt.I
.get<AvatarService>()
.fetchAndUpdateAvatarForJid(item.jid, item.avatarHash);
}
await GetIt.I.get<BlocklistService>().getBlocklist();
}
// Make sure we display our own avatar correctly.
// Note that this only requests the avatar if its hash differs from the locally cached avatar's.
// TODO(Unknown): Maybe don't do this on mobile Internet
unawaited(GetIt.I.get<AvatarService>().requestOwnAvatar());
if (_loginTriggeredFromUI) {
// TODO(Unknown): Trigger another event so the UI can see this aswell
await GetIt.I.get<XmppStateService>().modifyXmppState(
(state) => state.copyWith(
jid: connection.connectionSettings.jid.toString(),
displayName: connection.connectionSettings.jid.local,
avatarUrl: '',
avatarHash: '',
),
);
}
}
Future<void> _onConnectionStateChanged( Future<void> _onConnectionStateChanged(
ConnectionStateChangedEvent event, { ConnectionStateChangedEvent event, {
dynamic extra, dynamic extra,
@@ -773,71 +820,6 @@ class XmppService {
event.before, event.before,
event.state, event.state,
); );
if (event.state == XmppConnectionState.connected) {
final connection = GetIt.I.get<XmppConnection>();
// TODO(Unknown): Maybe have something better
final settings = connection.connectionSettings;
await GetIt.I.get<XmppStateService>().modifyXmppState(
(state) => state.copyWith(
jid: settings.jid.toString(),
password: settings.password,
),
);
_log.finest('Connection connected. Is resumed? ${event.resumed}');
unawaited(_initializeOmemoService(settings.jid.toString()));
if (!event.resumed) {
// Reset the blocking service's cache
GetIt.I.get<BlocklistService>().onNewConnection();
// Reset the OMEMO cache
GetIt.I.get<OmemoService>().onNewConnection();
// Enable carbons
final carbonsResult = await connection
.getManagerById<CarbonsManager>(carbonsManager)!
.enableCarbons();
if (!carbonsResult) {
_log.warning('Failed to enable carbons');
}
// In section 5 of XEP-0198 it says that a client should not request the roster
// in case of a stream resumption.
await connection
.getManagerById<RosterManager>(rosterManager)!
.requestRoster();
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
final roster = await GetIt.I.get<RosterService>().getRoster();
for (final item in roster) {
await GetIt.I
.get<AvatarService>()
.fetchAndUpdateAvatarForJid(item.jid, item.avatarHash);
}
await GetIt.I.get<BlocklistService>().getBlocklist();
}
// Make sure we display our own avatar correctly.
// Note that this only requests the avatar if its hash differs from the locally cached avatar's.
// TODO(Unknown): Maybe don't do this on mobile Internet
unawaited(GetIt.I.get<AvatarService>().requestOwnAvatar());
if (_loginTriggeredFromUI) {
// TODO(Unknown): Trigger another event so the UI can see this aswell
await GetIt.I.get<XmppStateService>().modifyXmppState(
(state) => state.copyWith(
jid: connection.connectionSettings.jid.toString(),
displayName: connection.connectionSettings.jid.local,
avatarUrl: '',
avatarHash: '',
),
);
}
}
} }
Future<void> _onResourceBound( Future<void> _onResourceBound(
@@ -876,11 +858,10 @@ class XmppService {
dynamic extra, dynamic extra,
}) async { }) async {
_log.finest('Received delivery receipt from ${event.from}'); _log.finest('Received delivery receipt from ${event.from}');
final db = GetIt.I.get<DatabaseService>();
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
final sender = event.from.toBare().toString(); final sender = event.from.toBare().toString();
final dbMsg = await db.getMessageByXmppId(event.id, sender); final dbMsg = await ms.getMessageByXmppId(event.id, sender);
if (dbMsg == null) { if (dbMsg == null) {
_log.warning( _log.warning(
'Did not find the message with id ${event.id} in the database!', 'Did not find the message with id ${event.id} in the database!',
@@ -907,11 +888,10 @@ class XmppService {
Future<void> _onChatMarker(ChatMarkerEvent event, {dynamic extra}) async { Future<void> _onChatMarker(ChatMarkerEvent event, {dynamic extra}) async {
_log.finest('Chat marker from ${event.from}'); _log.finest('Chat marker from ${event.from}');
final db = GetIt.I.get<DatabaseService>();
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
final sender = event.from.toBare().toString(); final sender = event.from.toBare().toString();
final dbMsg = await db.getMessageByXmppId(event.id, sender); final dbMsg = await ms.getMessageByXmppId(event.id, sender);
if (dbMsg == null) { if (dbMsg == null) {
_log.warning('Did not find the message in the database!'); _log.warning('Did not find the message in the database!');
return; return;
@@ -1010,9 +990,8 @@ class XmppService {
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and // True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null. // that the message body and the OOB url are the same if the OOB url is not null.
return embeddedFile != null && return embeddedFile != null &&
Uri.parse(embeddedFile.url).scheme == 'https' && Uri.parse(embeddedFile.urls.first).scheme == 'https' &&
implies(event.oob != null, event.body == event.oob?.url) && implies(event.oob != null, event.body == event.oob?.url);
event.stickerPackId == null;
} }
/// Handle a message retraction given the MessageEvent [event]. /// Handle a message retraction given the MessageEvent [event].
@@ -1141,9 +1120,10 @@ class XmppService {
) async { ) async {
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id // TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
final msg = await ms.getMessageByStanzaOrOriginId( final msg = await ms.getMessageByXmppId(
conversationJid,
event.messageReactions!.messageId, event.messageReactions!.messageId,
conversationJid,
queryReactionPreview: false,
); );
if (msg == null) { if (msg == null) {
_log.warning( _log.warning(
@@ -1152,81 +1132,11 @@ class XmppService {
return; return;
} }
final state = await GetIt.I.get<XmppStateService>().getXmppState(); await GetIt.I.get<ReactionsService>().processNewReactions(
final sender = event.fromJid.toBare().toString(); msg,
final isCarbon = sender == state.jid; event.fromJid.toBare().toString(),
final reactions = List<Reaction>.from(msg.reactions); event.messageReactions!.emojis,
final emojis = event.messageReactions!.emojis;
// Find out what emojis the sender has already sent
final sentEmojis = msg.reactions
.where((r) {
return isCarbon ? r.reactedBySelf : r.senders.contains(sender);
})
.map((r) => r.emoji)
.toList();
// Find out what reactions were removed
final removedEmojis = sentEmojis.where((e) => !emojis.contains(e));
for (final emoji in emojis) {
final i = reactions.indexWhere((r) => r.emoji == emoji);
if (i == -1) {
reactions.add(
Reaction(
isCarbon ? [] : [sender],
emoji,
isCarbon,
),
); );
} else {
List<String> senders;
if (isCarbon) {
senders = reactions[i].senders;
} else {
// Ensure that we don't add a sender multiple times to the same reaction
if (reactions[i].senders.contains(sender)) {
senders = reactions[i].senders;
} else {
senders = [
...reactions[i].senders,
sender,
];
}
}
reactions[i] = reactions[i].copyWith(
senders: senders,
reactedBySelf: isCarbon ? true : reactions[i].reactedBySelf,
);
}
}
for (final emoji in removedEmojis) {
final i = reactions.indexWhere((r) => r.emoji == emoji);
assert(i >= -1, 'The reaction must exist');
if (isCarbon && reactions[i].senders.isEmpty ||
!isCarbon &&
reactions[i].senders.length == 1 &&
!reactions[i].reactedBySelf) {
reactions.removeAt(i);
} else {
reactions[i] = reactions[i].copyWith(
senders: isCarbon
? reactions[i].senders
: reactions[i].senders.where((s) => s != sender).toList(),
reactedBySelf: isCarbon ? false : reactions[i].reactedBySelf,
);
}
}
final newMessage = await ms.updateMessage(
msg.id,
reactions: reactions,
);
sendEvent(
MessageUpdatedEvent(message: newMessage),
);
} }
Future<void> _onMessage(MessageEvent event, {dynamic extra}) async { Future<void> _onMessage(MessageEvent event, {dynamic extra}) async {
@@ -1313,84 +1223,64 @@ class XmppService {
} }
// The Url of the file embedded in the message, if there is one. // The Url of the file embedded in the message, if there is one.
final embeddedFile = _getMessageSrcUrl(event); final embeddedFile = _getEmbeddedFile(event);
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and // True if we determine a file to be embedded. Checks if the Url is using HTTPS and
// that the message body and the OOB url are the same if the OOB url is not null. // that the message body and the OOB url are the same if the OOB url is not null.
final isFileEmbedded = _isFileEmbedded(event, embeddedFile); final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
// The dimensions of the file, if available.
final dimensions = _getDimensions(event);
// Indicates if we should auto-download the file, if a file is specified in the message // Indicates if we should auto-download the file, if a file is specified in the message
final shouldDownload = await _shouldDownloadFile(conversationJid); final shouldDownload =
// The thumbnail for the embedded file. isFileEmbedded && await _shouldDownloadFile(conversationJid);
final thumbnailData = _getThumbnailData(event);
// Indicates if a notification should be created for the message. // Indicates if a notification should be created for the message.
// The way this variable works is that if we can download the file, then the // The way this variable works is that if we can download the file, then the
// notification will be created later by the [DownloadService]. If we don't want the // notification will be created later by the [DownloadService]. If we don't want the
// download to happen automatically, then the notification should happen immediately. // download to happen automatically, then the notification should happen immediately.
var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload); var shouldNotify = !(isInRoster && shouldDownload);
// A guess for the Mime type of the embedded file. // A guess for the Mime type of the embedded file.
var mimeGuess = _getMimeGuess(event); var mimeGuess = _getMimeGuess(event);
// Guess a sticker hash key, if the message is a sticker
final stickerHashKey = event.stickerPackId != null
? getStickerHashKey(event.sfs!.metadata.hashes)
: null;
// The potential sticker pack
final stickerPack = event.stickerPackId != null
? await GetIt.I.get<StickersService>().getStickerPackById(
event.stickerPackId!,
)
: null;
// Automatically download the sticker pack, if FileMetadataWrapper? fileMetadata;
// - a sticker was received, if (isFileEmbedded) {
// - the sender is in the roster, final thumbnail = _getThumbnailData(event);
// - we don't have the sticker pack locally, fileMetadata =
// - and it is enabled in the settings await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
if (event.stickerPackId != null && embeddedFile!,
stickerPack == null && mimeGuess,
prefs.autoDownloadStickersFromContacts && embeddedFile.size,
isInRoster) { dimensions,
unawaited( // TODO(Unknown): Maybe we switch to something else?
GetIt.I.get<StickersService>().importFromPubSubWithEvent( thumbnail != null ? 'blurhash' : null,
event.fromJid, thumbnail,
event.stickerPackId!, createHashPointers: false,
), );
);
} }
// Create the message in the database // Create the message in the database
final ms = GetIt.I.get<MessageService>(); final ms = GetIt.I.get<MessageService>();
final dimensions = _getDimensions(event);
var message = await ms.addMessageFromData( var message = await ms.addMessageFromData(
messageBody, messageBody,
messageTimestamp, messageTimestamp,
event.fromJid.toString(), event.fromJid.toString(),
conversationJid, conversationJid,
isFileEmbedded || event.fun != null || event.stickerPackId != null,
event.sid, event.sid,
event.fun != null, event.fun != null,
event.encrypted, event.encrypted,
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ?? event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ??
false, false,
srcUrl: embeddedFile?.url, fileMetadata: fileMetadata?.fileMetadata,
filename: event.fun?.name ?? embeddedFile?.filename,
key: embeddedFile?.keyBase64,
iv: embeddedFile?.ivBase64,
encryptionScheme: embeddedFile?.encryptionScheme,
mediaType: mimeGuess,
thumbnailData: thumbnailData,
mediaWidth: dimensions?.width.toInt(),
mediaHeight: dimensions?.height.toInt(),
quoteId: replyId, quoteId: replyId,
originId: event.stanzaId.originId, originId: event.originId,
errorType: errorTypeFromException(event.other['encryption_error']), errorType: errorTypeFromException(event.other['encryption_error']),
plaintextHashes: event.sfs?.metadata.hashes,
stickerPackId: event.stickerPackId, stickerPackId: event.stickerPackId,
stickerHashKey: stickerHashKey,
); );
// Attempt to auto-download the embedded file // Attempt to auto-download the embedded file, if
if (isFileEmbedded && shouldDownload) { // - there is a file attached and
// - we have not retrieved the file metadata
if (shouldDownload && !(fileMetadata?.retrieved ?? false)) {
final fts = GetIt.I.get<HttpFileTransferService>(); final fts = GetIt.I.get<HttpFileTransferService>();
final metadata = await peekFile(embeddedFile!.url); final metadata = await peekFile(embeddedFile!.urls.first);
_log.finest('Advertised file MIME: ${metadata.mime}'); _log.finest('Advertised file MIME: ${metadata.mime}');
if (metadata.mime != null) mimeGuess = metadata.mime; if (metadata.mime != null) mimeGuess = metadata.mime;
@@ -1408,6 +1298,7 @@ class XmppService {
FileDownloadJob( FileDownloadJob(
embeddedFile, embeddedFile,
message.id, message.id,
message.fileMetadata!.id,
conversationJid, conversationJid,
mimeGuess, mimeGuess,
), ),
@@ -1416,6 +1307,10 @@ class XmppService {
// Make sure we create the notification // Make sure we create the notification
shouldNotify = true; shouldNotify = true;
} }
} else {
if (fileMetadata?.retrieved ?? false) {
_log.info('Not downloading file as we already have it locally');
}
} }
final cs = GetIt.I.get<ConversationService>(); final cs = GetIt.I.get<ConversationService>();
@@ -1448,9 +1343,6 @@ class XmppService {
true, true,
prefs.defaultMuteState, prefs.defaultMuteState,
message.encrypted, message.encrypted,
// Always use 0 here, since a possible shared media item only is created
// afterwards.
0,
contactId, contactId,
await css.getProfilePicturePathForJid(conversationJid), await css.getProfilePicturePathForJid(conversationJid),
await css.getContactDisplayName(contactId), await css.getContactDisplayName(contactId),
@@ -1544,23 +1436,35 @@ class XmppService {
} }
// The Url of the file embedded in the message, if there is one. // The Url of the file embedded in the message, if there is one.
final embeddedFile = _getMessageSrcUrl(event); final embeddedFile = _getEmbeddedFile(event);
// Is there even a file we can download? // Is there even a file we can download?
final isFileEmbedded = _isFileEmbedded(event, embeddedFile); final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
if (isFileEmbedded) { if (isFileEmbedded) {
final shouldDownload = await _shouldDownloadFile(conversationJid); final fileMetadata =
await GetIt.I.get<FilesService>().getFileMetadataFromHash(
embeddedFile!.plaintextHashes,
);
final shouldDownload =
await _shouldDownloadFile(conversationJid) && fileMetadata == null;
final oldFileMetadata = message.fileMetadata;
message = await ms.updateMessage( message = await ms.updateMessage(
message.id, message.id,
srcUrl: embeddedFile!.url, fileMetadata: fileMetadata ?? notSpecified,
key: embeddedFile.keyBase64,
iv: embeddedFile.ivBase64,
isFileUploadNotification: false, isFileUploadNotification: false,
isDownloading: shouldDownload, isDownloading: shouldDownload,
sid: event.sid, sid: event.sid,
originId: event.stanzaId.originId, originId: event.originId,
); );
// Remove the old entry
if (fileMetadata != null) {
await GetIt.I
.get<FilesService>()
.removeFileMetadata(oldFileMetadata!.id);
}
// Tell the UI // Tell the UI
sendEvent(MessageUpdatedEvent(message: message)); sendEvent(MessageUpdatedEvent(message: message));
@@ -1570,11 +1474,16 @@ class XmppService {
FileDownloadJob( FileDownloadJob(
embeddedFile, embeddedFile,
message.id, message.id,
oldFileMetadata!.id,
conversationJid, conversationJid,
_getMimeGuess(event), _getMimeGuess(event),
shouldShowNotification: false, shouldShowNotification: false,
), ),
); );
} else {
if (fileMetadata != null) {
_log.info('Not downloading file as we already have it locally');
}
} }
} else { } else {
_log.warning( _log.warning(

View File

@@ -1,6 +1,8 @@
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart'; import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/shared/models/xmpp_state.dart'; import 'package:moxxyv2/shared/models/xmpp_state.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
class XmppStateService { class XmppStateService {
/// Persistent state around the connection, like the SM token, etc. /// Persistent state around the connection, like the SM token, etc.
@@ -9,13 +11,29 @@ class XmppStateService {
Future<XmppState> getXmppState() async { Future<XmppState> getXmppState() async {
if (_state != null) return _state!; if (_state != null) return _state!;
_state = await GetIt.I.get<DatabaseService>().getXmppState(); final json = <String, String?>{};
final rowsRaw =
await GetIt.I.get<DatabaseService>().database.query(xmppStateTable);
for (final row in rowsRaw) {
json[row['key']! as String] = row['value'] as String?;
}
_state = XmppState.fromDatabaseTuples(json);
return _state!; return _state!;
} }
/// A wrapper to modify the [XmppState] and commit it. /// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(XmppState Function(XmppState) func) async { Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
_state = func(_state!); _state = func(_state!);
await GetIt.I.get<DatabaseService>().saveXmppState(_state!);
final batch = GetIt.I.get<DatabaseService>().database.batch();
for (final tuple in _state!.toDatabaseTuples().entries) {
batch.insert(
xmppStateTable,
<String, String?>{'key': tuple.key, 'value': tuple.value},
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit();
} }
} }

View File

@@ -2,6 +2,7 @@ import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart'; import 'package:moxxyv2/shared/models/sticker_pack.dart';
part 'commands.moxxy.dart'; part 'commands.moxxy.dart';

View File

@@ -1,10 +1,10 @@
import 'package:moxlib/awaitabledatasender.dart'; import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/omemo_device.dart'; import 'package:moxxyv2/shared/models/omemo_device.dart';
import 'package:moxxyv2/shared/models/preferences.dart'; import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/reaction_group.dart';
import 'package:moxxyv2/shared/models/roster.dart'; import 'package:moxxyv2/shared/models/roster.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart'; import 'package:moxxyv2/shared/models/sticker_pack.dart';

View File

@@ -2,7 +2,6 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart'; import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
@@ -58,7 +57,6 @@ class Conversation with _$Conversation {
ConversationType type, ConversationType type,
// NOTE: In milliseconds since Epoch or -1 if none has ever happened // NOTE: In milliseconds since Epoch or -1 if none has ever happened
int lastChangeTimestamp, int lastChangeTimestamp,
List<SharedMedium> sharedMedia,
// Indicates if the conversation should be shown on the homescreen // Indicates if the conversation should be shown on the homescreen
bool open, bool open,
// Indicates, if [jid] is a regular user, if the user is in the roster. // Indicates, if [jid] is a regular user, if the user is in the roster.
@@ -70,9 +68,7 @@ class Conversation with _$Conversation {
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted) // Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
bool encrypted, bool encrypted,
// The current chat state // The current chat state
@ConversationChatStateConverter() ChatState chatState, @ConversationChatStateConverter() ChatState chatState, {
// The amount of shared media items that are in the database
int sharedMediaAmount, {
// The id of the contact in the device's phonebook if it exists // The id of the contact in the device's phonebook if it exists
String? contactId, String? contactId,
// The path to the contact avatar, if available // The path to the contact avatar, if available
@@ -91,12 +87,10 @@ class Conversation with _$Conversation {
Map<String, dynamic> json, Map<String, dynamic> json,
bool inRoster, bool inRoster,
String subscription, String subscription,
List<SharedMedium> sharedMedia,
Message? lastMessage, Message? lastMessage,
) { ) {
return Conversation.fromJson({ return Conversation.fromJson({
...json, ...json,
'sharedMedia': <Map<String, dynamic>>[],
'muted': intToBool(json['muted']! as int), 'muted': intToBool(json['muted']! as int),
'open': intToBool(json['open']! as int), 'open': intToBool(json['open']! as int),
'inRoster': inRoster, 'inRoster': inRoster,
@@ -106,7 +100,6 @@ class Conversation with _$Conversation {
const ConversationChatStateConverter().toJson(ChatState.gone), const ConversationChatStateConverter().toJson(ChatState.gone),
}).copyWith( }).copyWith(
lastMessage: lastMessage, lastMessage: lastMessage,
sharedMedia: sharedMedia,
); );
} }
@@ -114,7 +107,6 @@ class Conversation with _$Conversation {
final map = toJson() final map = toJson()
..remove('id') ..remove('id')
..remove('chatState') ..remove('chatState')
..remove('sharedMedia')
..remove('inRoster') ..remove('inRoster')
..remove('subscription') ..remove('subscription')
..remove('lastMessage'); ..remove('lastMessage');
@@ -152,6 +144,9 @@ class Conversation with _$Conversation {
return title; return title;
} }
/// The amount of items that are shown in the context menu.
int get numberContextMenuOptions => 1 + (unreadCounter != 0 ? 1 : 0);
} }
/// Sorts conversations in descending order by their last change timestamp. /// Sorts conversations in descending order by their last change timestamp.

View File

@@ -0,0 +1,120 @@
import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart';
part 'file_metadata.freezed.dart';
part 'file_metadata.g.dart';
/// Wrapper for turning a map "Hash algorithm -> Hash value" [hashes] into a string
/// for storage in the database.
String serializeHashMap(Map<HashFunction, String> hashes) {
final rawMap =
hashes.map((key, value) => MapEntry<String, String>(key.toName(), value));
return jsonEncode(rawMap);
}
/// Wrapper for turning a string [hashString] into a map "Hash algorithm -> Hash value".
Map<HashFunction, String> deserializeHashMap(String hashString) {
final rawMap =
(jsonDecode(hashString) as Map<dynamic, dynamic>).cast<String, String>();
return rawMap.map(
(key, value) =>
MapEntry<HashFunction, String>(HashFunction.fromName(key), value),
);
}
@freezed
class FileMetadata with _$FileMetadata {
factory FileMetadata(
/// A unique ID
String id,
/// The path where the file can be found.
String? path,
/// The source where the file came from.
List<String>? sourceUrls,
/// The MIME type of the media, if available.
String? mimeType,
/// The size in bytes of the file, if available.
int? size,
/// The type of thumbnail data we have, if [thumbnailData] is non-null.
String? thumbnailType,
/// String-encodable thumbnail data, like blurhash.
String? thumbnailData,
/// Media dimensions, if the media file has such attributes.
int? width,
int? height,
/// A list of hashes for the original plaintext file.
Map<HashFunction, String>? plaintextHashes,
/// If non-null: The key the file was encrypted with.
String? encryptionKey,
/// If non-null: The IV used for encryption.
String? encryptionIv,
/// If non-null: The encryption method used for encrypting the file.
String? encryptionScheme,
/// A list of hashes for the encrypted file.
Map<HashFunction, String>? ciphertextHashes,
/// The actual filename of the file. If the filename was obfuscated, e.g. due
/// to encryption, this should be the original filename.
String filename,
) = _FileMetadata;
const FileMetadata._();
/// JSON
factory FileMetadata.fromJson(Map<String, dynamic> json) =>
_$FileMetadataFromJson(json);
factory FileMetadata.fromDatabaseJson(Map<String, dynamic> json) {
final plaintextHashesRaw = json['plaintextHashes'] as String?;
final plaintextHashes = plaintextHashesRaw != null
? deserializeHashMap(plaintextHashesRaw)
: null;
final ciphertextHashesRaw = json['ciphertextHashes'] as String?;
final ciphertextHashes = ciphertextHashesRaw != null
? deserializeHashMap(ciphertextHashesRaw)
: null;
final sourceUrlsRaw = json['sourceUrls'] as String?;
final sourceUrls = sourceUrlsRaw == null
? null
: (jsonDecode(sourceUrlsRaw) as List<dynamic>).cast<String>();
// Workaround for using enums as map keys
final modifiedJson = Map<String, dynamic>.from(json)
..remove('plaintextHashes')
..remove('ciphertextHashes');
return FileMetadata.fromJson({
...modifiedJson,
'sourceUrls': sourceUrls,
}).copyWith(
plaintextHashes: plaintextHashes,
ciphertextHashes: ciphertextHashes,
);
}
Map<String, dynamic> toDatabaseJson() {
final map = toJson()
..remove('plaintextHashes')
..remove('ciphertextHashes')
..remove('sourceUrls');
return {
...map,
'plaintextHashes':
plaintextHashes != null ? serializeHashMap(plaintextHashes!) : null,
'ciphertextHashes':
ciphertextHashes != null ? serializeHashMap(ciphertextHashes!) : null,
'sourceUrls': sourceUrls != null ? jsonEncode(sourceUrls) : null,
};
}
}

View File

@@ -1,41 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'media.freezed.dart';
part 'media.g.dart';
@freezed
class SharedMedium with _$SharedMedium {
factory SharedMedium(
int id,
String path,
int timestamp,
String conversationJid, {
String? mime,
int? messageId,
}) = _SharedMedia;
const SharedMedium._();
/// JSON
factory SharedMedium.fromJson(Map<String, dynamic> json) =>
_$SharedMediumFromJson(json);
factory SharedMedium.fromDatabaseJson(Map<String, dynamic> json) {
return SharedMedium.fromJson({
...json,
'messageId': json['message_id'] as int?,
'conversationJid': json['conversation_jid']! as String,
});
}
Map<String, dynamic> toDatabaseJson() {
return {
...toJson()
..remove('id')
..remove('messageId')
..remove('conversationJid'),
'message_id': messageId,
'conversation_jid': conversationJid,
};
}
}

View File

@@ -3,7 +3,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxyv2/service/database/helpers.dart'; import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/error_types.dart'; import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/helpers.dart'; import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/reaction.dart'; import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/warning_types.dart'; import 'package:moxxyv2/shared/warning_types.dart';
part 'message.freezed.dart'; part 'message.freezed.dart';
@@ -11,24 +11,12 @@ part 'message.g.dart';
const pseudoMessageTypeNewDevice = 1; const pseudoMessageTypeNewDevice = 1;
Map<String, String>? _optionalJsonDecode(String? data) {
if (data == null) return null;
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String>();
}
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) { Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
if (data == null) return <String, dynamic>{}; if (data == null) return <String, dynamic>{};
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>(); return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
} }
String? _optionalJsonEncode(Map<String, dynamic>? data) {
if (data == null) return null;
return jsonEncode(data);
}
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) { String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
if (data == null) return null; if (data == null) return null;
if (data.isEmpty) return null; if (data.isEmpty) return null;
@@ -46,26 +34,15 @@ class Message with _$Message {
// The database-internal identifier of the message // The database-internal identifier of the message
int id, int id,
String conversationJid, String conversationJid,
// True if the message contains some embedded media
bool isMedia,
bool isFileUploadNotification, bool isFileUploadNotification,
bool encrypted, bool encrypted,
// True if the message contains a <no-store> Message Processing Hint. False if not // True if the message contains a <no-store> Message Processing Hint. False if not
bool containsNoStore, { bool containsNoStore, {
int? errorType, int? errorType,
int? warningType, int? warningType,
String? mediaUrl, FileMetadata? fileMetadata,
@Default(false) bool isDownloading, @Default(false) bool isDownloading,
@Default(false) bool isUploading, @Default(false) bool isUploading,
String? mediaType,
String? thumbnailData,
int? mediaWidth,
int? mediaHeight,
// If non-null: Indicates where some media entry originated/originates from
String? srcUrl,
String? key,
String? iv,
String? encryptionScheme,
@Default(false) bool received, @Default(false) bool received,
@Default(false) bool displayed, @Default(false) bool displayed,
@Default(false) bool acked, @Default(false) bool acked,
@@ -73,13 +50,8 @@ class Message with _$Message {
@Default(false) bool isEdited, @Default(false) bool isEdited,
String? originId, String? originId,
Message? quotes, Message? quotes,
String? filename, @Default([]) List<String> reactionsPreview,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
int? mediaSize,
@Default([]) List<Reaction> reactions,
String? stickerPackId, String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType, int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData, Map<String, dynamic>? pseudoMessageData,
}) = _Message; }) = _Message;
@@ -90,34 +62,31 @@ class Message with _$Message {
factory Message.fromJson(Map<String, dynamic> json) => factory Message.fromJson(Map<String, dynamic> json) =>
_$MessageFromJson(json); _$MessageFromJson(json);
factory Message.fromDatabaseJson(Map<String, dynamic> json, Message? quotes) { factory Message.fromDatabaseJson(
Map<String, dynamic> json,
Message? quotes,
FileMetadata? fileMetadata,
List<String> reactionsPreview,
) {
return Message.fromJson({ return Message.fromJson({
...json, ...json,
'received': intToBool(json['received']! as int), 'received': intToBool(json['received']! as int),
'displayed': intToBool(json['displayed']! as int), 'displayed': intToBool(json['displayed']! as int),
'acked': intToBool(json['acked']! as int), 'acked': intToBool(json['acked']! as int),
'isMedia': intToBool(json['isMedia']! as int),
'isFileUploadNotification': 'isFileUploadNotification':
intToBool(json['isFileUploadNotification']! as int), intToBool(json['isFileUploadNotification']! as int),
'encrypted': intToBool(json['encrypted']! as int), 'encrypted': intToBool(json['encrypted']! as int),
'plaintextHashes':
_optionalJsonDecode(json['plaintextHashes'] as String?),
'ciphertextHashes':
_optionalJsonDecode(json['ciphertextHashes'] as String?),
'isDownloading': intToBool(json['isDownloading']! as int), 'isDownloading': intToBool(json['isDownloading']! as int),
'isUploading': intToBool(json['isUploading']! as int), 'isUploading': intToBool(json['isUploading']! as int),
'isRetracted': intToBool(json['isRetracted']! as int), 'isRetracted': intToBool(json['isRetracted']! as int),
'isEdited': intToBool(json['isEdited']! as int), 'isEdited': intToBool(json['isEdited']! as int),
'containsNoStore': intToBool(json['containsNoStore']! as int), 'containsNoStore': intToBool(json['containsNoStore']! as int),
'reactions': <Map<String, dynamic>>[], 'reactionsPreview': reactionsPreview,
'pseudoMessageData': 'pseudoMessageData':
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?) _optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
}).copyWith( }).copyWith(
quotes: quotes, quotes: quotes,
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>) fileMetadata: fileMetadata,
.cast<Map<String, dynamic>>()
.map<Reaction>(Reaction.fromJson)
.toList(),
); );
} }
@@ -125,29 +94,25 @@ class Message with _$Message {
final map = toJson() final map = toJson()
..remove('id') ..remove('id')
..remove('quotes') ..remove('quotes')
..remove('reactions') ..remove('reactionsPreview')
..remove('fileMetadata')
..remove('pseudoMessageData'); ..remove('pseudoMessageData');
return { return {
...map, ...map,
'isMedia': boolToInt(isMedia),
'isFileUploadNotification': boolToInt(isFileUploadNotification), 'isFileUploadNotification': boolToInt(isFileUploadNotification),
'received': boolToInt(received), 'received': boolToInt(received),
'displayed': boolToInt(displayed), 'displayed': boolToInt(displayed),
'acked': boolToInt(acked), 'acked': boolToInt(acked),
'encrypted': boolToInt(encrypted), 'encrypted': boolToInt(encrypted),
'file_metadata_id': fileMetadata?.id,
// NOTE: Message.quote_id is a foreign-key // NOTE: Message.quote_id is a foreign-key
'quote_id': quotes?.id, 'quote_id': quotes?.id,
'plaintextHashes': _optionalJsonEncode(plaintextHashes),
'ciphertextHashes': _optionalJsonEncode(ciphertextHashes),
'isDownloading': boolToInt(isDownloading), 'isDownloading': boolToInt(isDownloading),
'isUploading': boolToInt(isUploading), 'isUploading': boolToInt(isUploading),
'isRetracted': boolToInt(isRetracted), 'isRetracted': boolToInt(isRetracted),
'isEdited': boolToInt(isEdited), 'isEdited': boolToInt(isEdited),
'containsNoStore': boolToInt(containsNoStore), 'containsNoStore': boolToInt(containsNoStore),
'reactions': jsonEncode(
reactions.map((r) => r.toJson()).toList(),
),
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData), 'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
}; };
} }
@@ -161,7 +126,7 @@ class Message with _$Message {
/// Returns a representative emoji for a message. Its primary purpose is /// Returns a representative emoji for a message. Its primary purpose is
/// to provide a universal fallback for quoted media messages. /// to provide a universal fallback for quoted media messages.
String get messageEmoji { String get messageEmoji {
return mimeTypeToEmoji(mediaType, addTypeName: false); return mimeTypeToEmoji(fileMetadata?.mimeType, addTypeName: false);
} }
/// True if the message is a pseudo message. /// True if the message is a pseudo message.
@@ -224,19 +189,21 @@ class Message with _$Message {
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or /// Returns true if the message contains media that can be thumbnailed, i.e. videos or
/// images. /// images.
bool get isThumbnailable => bool get isThumbnailable {
!isPseudoMessage && if (isPseudoMessage || !isMedia || fileMetadata?.mimeType == null) {
isMedia && return false;
mediaType != null && }
(mediaType!.startsWith('image/') || mediaType!.startsWith('video/'));
final mimeType = fileMetadata!.mimeType!;
return mimeType.startsWith('image/') || mimeType.startsWith('video/');
}
/// Returns true if the message can be copied to the clipboard. /// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage; bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
/// Returns true if the message is a sticker /// Returns true if the message is a sticker
bool get isSticker => bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
isMedia &&
stickerPackId != null && /// True if the message is a media message
stickerHashKey != null && bool get isMedia => fileMetadata != null;
!isPseudoMessage;
} }

View File

@@ -6,22 +6,14 @@ part 'reaction.g.dart';
@freezed @freezed
class Reaction with _$Reaction { class Reaction with _$Reaction {
factory Reaction( factory Reaction(
List<String> senders, // This is valid in combination with freezed
// ignore: invalid_annotation_target
@JsonKey(name: 'message_id') int messageId,
String senderJid,
String emoji, String emoji,
// NOTE: Store this with the model to prevent having to to a O(n) search across the
// list of reactions on every rebuild
bool reactedBySelf,
) = _Reaction; ) = _Reaction;
const Reaction._();
/// JSON /// JSON
factory Reaction.fromJson(Map<String, dynamic> json) => factory Reaction.fromJson(Map<String, dynamic> json) =>
_$ReactionFromJson(json); _$ReactionFromJson(json);
int get reactions {
if (reactedBySelf) return senders.length + 1;
return senders.length;
}
} }

View File

@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'reaction_group.freezed.dart';
part 'reaction_group.g.dart';
@freezed
class ReactionGroup with _$ReactionGroup {
factory ReactionGroup(
String jid,
List<String> emojis,
) = _ReactionGroup;
/// JSON
factory ReactionGroup.fromJson(Map<String, dynamic> json) =>
_$ReactionGroupFromJson(json);
}

View File

@@ -1,7 +1,10 @@
import 'dart:convert'; import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp; import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
import 'package:moxxyv2/service/helpers.dart'; import 'package:moxxyv2/service/helpers.dart';
import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:path/path.dart' as path;
part 'sticker.freezed.dart'; part 'sticker.freezed.dart';
part 'sticker.g.dart'; part 'sticker.g.dart';
@@ -9,84 +12,89 @@ part 'sticker.g.dart';
@freezed @freezed
class Sticker with _$Sticker { class Sticker with _$Sticker {
factory Sticker( factory Sticker(
String hashKey, String id,
String mediaType,
String desc,
int size,
int? width,
int? height,
/// Hash algorithm (algo attribute) -> Base64 encoded hash
Map<String, String> hashes,
List<String> urlSources,
String path,
String stickerPackId, String stickerPackId,
String desc,
Map<String, String> suggests, Map<String, String> suggests,
FileMetadata fileMetadata,
) = _Sticker; ) = _Sticker;
const Sticker._(); const Sticker._();
/// Moxxmpp /// Moxxmpp
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) => factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) {
Sticker( final hashKey = getStickerHashKey(sticker.metadata.hashes);
getStickerHashKey(sticker.metadata.hashes), final firstUrl = (sticker.sources.firstWhereOrNull(
sticker.metadata.mediaType!, (src) => src is moxxmpp.StatelessFileSharingUrlSource,
sticker.metadata.desc!, )! as moxxmpp.StatelessFileSharingUrlSource)
sticker.metadata.size!, .url;
sticker.metadata.width, return Sticker(
sticker.metadata.height, hashKey,
sticker.metadata.hashes, stickerPackId,
sticker.metadata.desc!,
sticker.suggests,
FileMetadata(
hashKey,
null,
sticker.sources sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>() .whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((src) => src.url) .map((src) => src.url)
.toList(), .toList(),
'', sticker.metadata.mediaType,
stickerPackId, sticker.metadata.size,
sticker.suggests, null,
); null,
sticker.metadata.width,
sticker.metadata.height,
sticker.metadata.hashes,
null,
null,
null,
null,
sticker.metadata.name ?? path.basename(firstUrl),
),
);
}
/// JSON /// JSON
factory Sticker.fromJson(Map<String, dynamic> json) => factory Sticker.fromJson(Map<String, dynamic> json) =>
_$StickerFromJson(json); _$StickerFromJson(json);
factory Sticker.fromDatabaseJson(Map<String, dynamic> json) { factory Sticker.fromDatabaseJson(
Map<String, dynamic> json,
FileMetadata fileMetadata,
) {
return Sticker.fromJson({ return Sticker.fromJson({
...json, ...json,
'hashes': (jsonDecode(json['hashes']! as String) as Map<dynamic, dynamic>)
.cast<String, String>(),
'urlSources': (jsonDecode(json['urlSources']! as String) as List<dynamic>)
.cast<String>(),
'suggests': 'suggests':
(jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>) (jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>)
.cast<String, String>(), .cast<String, String>(),
'fileMetadata': fileMetadata.toJson(),
}); });
} }
Map<String, dynamic> toDatabaseJson() { Map<String, dynamic> toDatabaseJson() {
final map = toJson() final map = toJson()..remove('fileMetadata');
..remove('hashes')
..remove('urlSources')
..remove('suggests');
return { return {
...map, ...map,
'hashes': jsonEncode(hashes),
'urlSources': jsonEncode(urlSources),
'suggests': jsonEncode(suggests), 'suggests': jsonEncode(suggests),
'file_metadata_id': fileMetadata.id,
}; };
} }
moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker( moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker(
moxxmpp.FileMetadataData( moxxmpp.FileMetadataData(
mediaType: mediaType, mediaType: fileMetadata.mimeType,
desc: desc, desc: desc,
size: size, size: fileMetadata.size,
width: width, width: fileMetadata.width,
height: height, height: fileMetadata.height,
thumbnails: [], thumbnails: [],
hashes: hashes, hashes: fileMetadata.plaintextHashes,
), ),
urlSources fileMetadata.sourceUrls!
// Dart has some issues with using a constructor in a map
// ignore: unnecessary_lambdas // ignore: unnecessary_lambdas
.map((src) => moxxmpp.StatelessFileSharingUrlSource(src)) .map((src) => moxxmpp.StatelessFileSharingUrlSource(src))
.toList(), .toList(),
@@ -94,5 +102,5 @@ class Sticker with _$Sticker {
); );
/// True, if the sticker is backed by an image with MIME type image/*. /// True, if the sticker is backed by an image with MIME type image/*.
bool get isImage => mediaType.startsWith('image/'); bool get isImage => fileMetadata.mimeType?.startsWith('image/') ?? false;
} }

View File

@@ -69,7 +69,7 @@ class StickerPack with _$StickerPack {
id, id,
name, name,
description, description,
moxxmpp.hashFunctionFromName(hashAlgorithm), moxxmpp.HashFunction.fromName(hashAlgorithm),
hashValue, hashValue,
stickers.map((sticker) => sticker.toMoxxmpp()).toList(), stickers.map((sticker) => sticker.toMoxxmpp()).toList(),
restricted, restricted,

View File

@@ -1,24 +1,16 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart'; import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/models/conversation.dart'; import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart'; import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
part 'conversation_bloc.freezed.dart'; part 'conversation_bloc.freezed.dart';
part 'conversation_event.dart'; part 'conversation_event.dart';
@@ -36,19 +28,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<ImagePickerRequestedEvent>(_onImagePickerRequested); on<ImagePickerRequestedEvent>(_onImagePickerRequested);
on<FilePickerRequestedEvent>(_onFilePickerRequested); on<FilePickerRequestedEvent>(_onFilePickerRequested);
on<OmemoSetEvent>(_onOmemoSet); on<OmemoSetEvent>(_onOmemoSet);
on<SendButtonDragStartedEvent>(_onDragStarted);
on<SendButtonDragEndedEvent>(_onDragEnded);
on<SendButtonLockedEvent>(_onSendButtonLocked);
on<SendButtonLockPressedEvent>(_onSendButtonLockPressed);
on<RecordingCanceledEvent>(_onRecordingCanceled);
_audioRecorder = Record();
} }
/// The audio recorder
late Record _audioRecorder;
DateTime? _recordingStart;
bool _isSameConversation(String jid) => jid == state.conversation?.jid; bool _isSameConversation(String jid) => jid == state.conversation?.jid;
Future<void> _onInit( Future<void> _onInit(
@@ -135,14 +116,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
CurrentConversationResetEvent event, CurrentConversationResetEvent event,
Emitter<ConversationState> emit, Emitter<ConversationState> emit,
) async { ) async {
// Reset conversation so that we don't accidentally send chat states to chats
// that are not currently focused.
emit(
state.copyWith(
conversation: null,
),
);
await MoxplatformPlugin.handler.getDataSender().sendData( await MoxplatformPlugin.handler.getDataSender().sendData(
SetOpenConversationCommand(), SetOpenConversationCommand(),
awaitable: false, awaitable: false,
@@ -209,128 +182,4 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
awaitable: false, awaitable: false,
); );
} }
Future<void> _onDragStarted(
SendButtonDragStartedEvent event,
Emitter<ConversationState> emit,
) async {
final status = await Permission.speech.status;
if (status.isDenied) {
await Permission.speech.request();
return;
}
emit(
state.copyWith(
isDragging: true,
isRecording: true,
),
);
final now = DateTime.now();
_recordingStart = now;
final tempDir = await getTemporaryDirectory();
final timestamp =
'${now.year}${now.month}${now.day}${now.hour}${now.minute}${now.second}';
final tempFile = path.join(tempDir.path, 'audio_$timestamp.aac');
await _audioRecorder.start(
path: tempFile,
);
}
Future<void> _handleRecordingEnd() async {
// Prevent messages of really short duration being sent
final now = DateTime.now();
if (now.difference(_recordingStart!).inSeconds < 1) {
await Fluttertoast.showToast(
msg: t.warnings.conversation.holdForLonger,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return;
}
// Warn if something unexpected happened
final recordingPath = await _audioRecorder.stop();
if (recordingPath == null) {
await Fluttertoast.showToast(
msg: t.errors.conversation.audioRecordingError,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return;
}
// Send the file
await MoxplatformPlugin.handler.getDataSender().sendData(
SendFilesCommand(
paths: [recordingPath],
recipients: [state.conversation!.jid],
),
awaitable: false,
);
}
Future<void> _onDragEnded(
SendButtonDragEndedEvent event,
Emitter<ConversationState> emit,
) async {
final recording = state.isRecording;
emit(
state.copyWith(
isDragging: false,
isLocked: false,
isRecording: false,
),
);
if (recording) {
await _handleRecordingEnd();
}
}
Future<void> _onSendButtonLocked(
SendButtonLockedEvent event,
Emitter<ConversationState> emit,
) async {
Vibrate.feedback(FeedbackType.light);
emit(state.copyWith(isLocked: true));
}
Future<void> _onSendButtonLockPressed(
SendButtonLockPressedEvent event,
Emitter<ConversationState> emit,
) async {
final recording = state.isRecording;
emit(
state.copyWith(
isLocked: false,
isDragging: false,
isRecording: false,
),
);
if (recording) {
await _handleRecordingEnd();
}
}
Future<void> _onRecordingCanceled(
RecordingCanceledEvent event,
Emitter<ConversationState> emit,
) async {
Vibrate.feedback(FeedbackType.heavy);
emit(
state.copyWith(
isLocked: false,
isDragging: false,
isRecording: false,
),
);
final file = await _audioRecorder.stop();
unawaited(File(file!).delete());
}
} }

View File

@@ -1,9 +1,17 @@
part of 'conversation_bloc.dart'; part of 'conversation_bloc.dart';
enum SendButtonState { enum SendButtonState {
/// Open the speed dial when tapped.
multi, multi,
/// Send the current message when tapped.
send, send,
/// Cancel the current correction when tapped.
cancelCorrection, cancelCorrection,
/// Hide the button when we're recording an audio message.
hidden,
} }
const defaultSendButtonState = SendButtonState.multi; const defaultSendButtonState = SendButtonState.multi;

View File

@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
@@ -147,4 +148,10 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
awaitable: false, awaitable: false,
); );
} }
/// Return, if existent, the conversation from the state with a JID equal to [jid].
/// Returns null, if the conversation does not exist.
Conversation? getConversationByJid(String jid) {
return state.conversations.firstWhereOrNull((c) => c.jid == jid);
}
} }

View File

@@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/pages/profile/profile.dart';
part 'profile_bloc.freezed.dart'; part 'profile_bloc.freezed.dart';
part 'profile_event.dart'; part 'profile_event.dart';
@@ -28,7 +29,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
if (event.isSelfProfile) { if (event.isSelfProfile) {
emit( emit(
state.copyWith( state.copyWith(
isSelfProfile: true,
jid: event.jid!, jid: event.jid!,
avatarUrl: event.avatarUrl!, avatarUrl: event.avatarUrl!,
displayName: event.displayName!, displayName: event.displayName!,
@@ -37,7 +37,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
} else { } else {
emit( emit(
state.copyWith( state.copyWith(
isSelfProfile: false,
conversation: event.conversation, conversation: event.conversation,
), ),
); );
@@ -45,8 +44,12 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
GetIt.I.get<NavigationBloc>().add( GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent( PushedNamedEvent(
const NavigationDestination( NavigationDestination(
profileRoute, profileRoute,
arguments: ProfileArguments(
event.isSelfProfile,
event.jid ?? event.conversation!.jid,
),
), ),
), ),
); );

View File

@@ -36,7 +36,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
for (final sticker in pack.stickers) { for (final sticker in pack.stickers) {
if (!sticker.isImage) continue; if (!sticker.isImage) continue;
map[StickerKey(pack.id, sticker.hashKey)] = sticker; map[StickerKey(pack.id, sticker.id)] = sticker;
} }
} }
@@ -58,10 +58,10 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
)!; )!;
final sm = Map<StickerKey, Sticker>.from(state.stickerMap); final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
for (final sticker in stickerPack.stickers) { for (final sticker in stickerPack.stickers) {
sm.remove(StickerKey(stickerPack.id, sticker.hashKey)); sm.remove(StickerKey(stickerPack.id, sticker.id));
// Evict stickers from the cache // Evict stickers from the cache
unawaited(FileImage(File(sticker.path)).evict()); unawaited(FileImage(File(sticker.fileMetadata.path!)).evict());
} }
emit( emit(
@@ -105,7 +105,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
for (final sticker in result.stickerPack.stickers) { for (final sticker in result.stickerPack.stickers) {
if (!sticker.isImage) continue; if (!sticker.isImage) continue;
sm[StickerKey(result.stickerPack.id, sticker.hashKey)] = sticker; sm[StickerKey(result.stickerPack.id, sticker.id)] = sticker;
} }
emit( emit(
state.copyWith( state.copyWith(
@@ -146,7 +146,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
for (final sticker in event.stickerPack.stickers) { for (final sticker in event.stickerPack.stickers) {
if (!sticker.isImage) continue; if (!sticker.isImage) continue;
sm[StickerKey(event.stickerPack.id, sticker.hashKey)] = sticker; sm[StickerKey(event.stickerPack.id, sticker.id)] = sticker;
} }
emit( emit(

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
const Radius radiusLarge = Radius.circular(10); const double radiusLargeSize = 10;
const Radius radiusLarge = Radius.circular(radiusLargeSize);
const Radius radiusSmall = Radius.circular(4); const Radius radiusSmall = Radius.circular(4);
const double textfieldRadiusRegular = 15; const double textfieldRadiusRegular = 15;
const double textfieldRadiusConversation = 25; const double textfieldRadiusConversation = 25;
const double textfieldQuotedMessageRadius = textfieldRadiusConversation - 10;
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only( const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
top: 4, top: 4,
bottom: 4, bottom: 4,
@@ -93,6 +95,9 @@ const Color profileFallbackTextColorDark = Colors.white;
/// The text color of the buttons in the overlay of the ConversationPage /// The text color of the buttons in the overlay of the ConversationPage
const Color conversationOverlayButtonTextColor = Color(0xffcf4aff); const Color conversationOverlayButtonTextColor = Color(0xffcf4aff);
/// The background color of the context menu
const Color contextMenuBackgroundColor = Color(0xff515151);
const Color settingsSectionTitleColor = Color(0xffb72fe7); const Color settingsSectionTitleColor = Color(0xffb72fe7);
const double paddingVeryLarge = 64; const double paddingVeryLarge = 64;
@@ -117,9 +122,18 @@ final Color sharedMediaSummaryBackgroundColor = Colors.grey.shade500;
/// displaying the download progress indicator. /// displaying the download progress indicator.
final backdropBlack = Colors.black.withAlpha(150); final backdropBlack = Colors.black.withAlpha(150);
/// The height of the emoji/sticker picker /// The height of the emoji/sticker picker.
const double pickerHeight = 300; const double pickerHeight = 300;
/// The color of a reaction that is not from ourselves.
const Color reactionColorReceived = Color(0xff757575);
/// The color of a reaction that is sent by ourselves.
const Color reactionColorSent = Color(0xff2993FB);
/// The color of the skim when a message is highlighted.
const Color highlightSkimColor = Color(0xff000000);
/// Navigation constants /// Navigation constants
const String cropRoute = '/crop'; const String cropRoute = '/crop';
const String introRoute = '/intro'; const String introRoute = '/intro';

View File

@@ -29,10 +29,10 @@ class BidirectionalController<T> {
/// _cache.length - 1: The newest data item we know about /// _cache.length - 1: The newest data item we know about
final List<T> _cache = List<T>.empty(growable: true); final List<T> _cache = List<T>.empty(growable: true);
final StreamController<List<T>> _dataStreamController = final StreamController<List<T>> _dataStreamController =
StreamController<List<T>>(); StreamController<List<T>>.broadcast();
Stream<List<T>> get dataStream => _dataStreamController.stream; Stream<List<T>> get dataStream => _dataStreamController.stream;
@protected //@protected
List<T> get cache => _cache; List<T> get cache => _cache;
/// True if the cache has exceeded the size limit of pageSize * maxPageAmount. /// True if the cache has exceeded the size limit of pageSize * maxPageAmount.
@@ -40,8 +40,9 @@ class BidirectionalController<T> {
/// Flag indicating whether we are currently fetching data /// Flag indicating whether we are currently fetching data
bool _isFetching = false; bool _isFetching = false;
bool get isFetching => _isFetching;
final StreamController<bool> _isFetchingStreamController = final StreamController<bool> _isFetchingStreamController =
StreamController<bool>(); StreamController<bool>.broadcast();
Stream<bool> get isFetchingStream => _isFetchingStreamController.stream; Stream<bool> get isFetchingStream => _isFetchingStreamController.stream;
/// Flag indicating whether we are able to request newer data /// Flag indicating whether we are able to request newer data
@@ -53,7 +54,6 @@ class BidirectionalController<T> {
bool hasOlderData = true; bool hasOlderData = true;
/// Flag indicating whether data has been loaded at least once /// Flag indicating whether data has been loaded at least once
@protected
bool hasFetchedOnce = false; bool hasFetchedOnce = false;
/// True if we are scrolled to the bottom of the view. False, otherwise. /// True if we are scrolled to the bottom of the view. False, otherwise.

View File

@@ -1,16 +1,23 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/services.dart'; import 'dart:io';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:logging/logging.dart';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxmpp/moxxmpp.dart'; import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/constants.dart'; import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/reaction.dart'; import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation; import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart'; import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:record/record.dart';
class MessageEditingState { class MessageEditingState {
const MessageEditingState( const MessageEditingState(
@@ -37,7 +44,6 @@ class TextFieldData {
const TextFieldData( const TextFieldData(
this.isBodyEmpty, this.isBodyEmpty,
this.quotedMessage, this.quotedMessage,
this.pickerVisible,
); );
/// Flag indicating whether the current text input is empty. /// Flag indicating whether the current text input is empty.
@@ -45,9 +51,19 @@ class TextFieldData {
/// The currently quoted message. /// The currently quoted message.
final Message? quotedMessage; final Message? quotedMessage;
}
/// Flag indicating whether the picker is currently open or not. class RecordingData {
final bool pickerVisible; const RecordingData(
this.isRecording,
this.isLocked,
);
/// Flag indicating whether we are currently recording (true) or not (false).
final bool isRecording;
/// Flag indicating whether the recording draggable is locked (true) or not (false).
final bool isLocked;
} }
class BidirectionalConversationController class BidirectionalConversationController
@@ -62,21 +78,19 @@ class BidirectionalConversationController
maxPageAmount: maxMessagePages, maxPageAmount: maxMessagePages,
) { ) {
_textController.addListener(_handleTextChanged); _textController.addListener(_handleTextChanged);
_keyboardVisibilitySubscription = KeyboardVisibilityController()
.onChange
.listen(_handleSoftKeyboardVisibilityChanged);
BidirectionalConversationController.currentController = this; BidirectionalConversationController.currentController = this;
_updateChatState(ChatState.active); _updateChatState(ChatState.active);
} }
/// Logging.
final Logger _log = Logger('BidirectionalConversationController');
/// A singleton referring to the current instance as there can only be one /// A singleton referring to the current instance as there can only be one
/// BidirectionalConversationController at a time. /// BidirectionalConversationController at a time.
static BidirectionalConversationController? currentController; static BidirectionalConversationController? currentController;
late final StreamSubscription<bool> _keyboardVisibilitySubscription;
/// TextEditingController for the TextField /// TextEditingController for the TextField
final TextEditingController _textController = TextEditingController(); final TextEditingController _textController = TextEditingController();
TextEditingController get textController => _textController; TextEditingController get textController => _textController;
@@ -109,18 +123,21 @@ class BidirectionalConversationController
Stream<TextFieldData> get textFieldDataStream => Stream<TextFieldData> get textFieldDataStream =>
_textFieldDataStreamController.stream; _textFieldDataStreamController.stream;
/// Flag indicating whether the (emoji/sticker) picker is visible
bool _pickerVisible = false;
final StreamController<bool> _pickerVisibleStreamController =
StreamController.broadcast();
Stream<bool> get pickerVisibleStream => _pickerVisibleStreamController.stream;
/// The timer for managing the "compose" state /// The timer for managing the "compose" state
Timer? _composeTimer; Timer? _composeTimer;
/// The last time the TextField was modified /// The last time the TextField was modified
int _lastChangeTimestamp = 0; int _lastChangeTimestamp = 0;
/// Flag indicating whether we are currently recording an audio message (true) or not
/// (false).
final Record _audioRecorder = Record();
DateTime? _recordingStart;
final StreamController<RecordingData> _recordingAudioMessageStreamController =
StreamController<RecordingData>.broadcast();
Stream<RecordingData> get recordingAudioMessageStream =>
_recordingAudioMessageStreamController.stream;
void _updateChatState(ChatState state) { void _updateChatState(ChatState state) {
MoxplatformPlugin.handler.getDataSender().sendData( MoxplatformPlugin.handler.getDataSender().sendData(
SendChatStateCommand( SendChatStateCommand(
@@ -155,12 +172,6 @@ class BidirectionalConversationController
_composeTimer = null; _composeTimer = null;
} }
void _handleSoftKeyboardVisibilityChanged(bool visible) {
if (visible && _pickerVisible) {
togglePickerVisibility(false);
}
}
void _handleTextChanged() { void _handleTextChanged() {
final text = _textController.text; final text = _textController.text;
if (_messageEditingState != null) { if (_messageEditingState != null) {
@@ -181,7 +192,6 @@ class BidirectionalConversationController
TextFieldData( TextFieldData(
messageBody.isEmpty, messageBody.isEmpty,
_quotedMessage, _quotedMessage,
_pickerVisible,
), ),
); );
@@ -206,13 +216,28 @@ class BidirectionalConversationController
Future<void> onMessageReceived(Message message) async { Future<void> onMessageReceived(Message message) async {
// Drop the message if we don't really care about it // Drop the message if we don't really care about it
if (message.conversationJid != conversationJid) return; if (message.conversationJid != conversationJid) {
_log.finest(
"Not processing message as JIDs don't match: ${message.conversationJid} != $conversationJid",
);
return;
}
// TODO(Unknown): Guard against not being initialized yet, i.e. not having loaded the first // TODO(Unknown): This is probably not the best solution
// messages. if (isFetching) {
_log.finest('Not processing message as we are currently fetching');
return;
}
var shouldScrollToBottom = true; var shouldScrollToBottom = true;
if (message.timestamp < cache.last.timestamp) { if (cache.isEmpty && hasFetchedOnce) {
// We do this check here to prevent a StateException being thrown because
// the cache is empty. So just add the message.
addItem(message);
// As this is the first message, we don't have to scroll to the bottom.
shouldScrollToBottom = false;
} else if (message.timestamp < cache.last.timestamp) {
if (message.timestamp < cache.first.timestamp) { if (message.timestamp < cache.first.timestamp) {
// The message is older than the oldest message we know about. Drop it. // The message is older than the oldest message we know about. Drop it.
// It will be fetched when scrolling up. // It will be fetched when scrolling up.
@@ -264,97 +289,19 @@ class BidirectionalConversationController
); );
} }
/// Add [emoji] as a reaction to the message at index [index]. /// Send the sticker [sticker].
void addReaction(int index, String emoji) { void sendSticker(sticker.Sticker sticker) {
final message = cache[index];
final reactionIndex = message.reactions.indexWhere(
(Reaction r) => r.emoji == emoji,
);
if (reactionIndex != -1) {
// Ignore the request when the reaction would be invalid
final reaction = message.reactions[reactionIndex];
if (reaction.reactedBySelf) return;
final reactions = List<Reaction>.from(message.reactions);
reactions[reactionIndex] = reaction.copyWith(
reactedBySelf: true,
);
cache[index] = cache[index].copyWith(
reactions: reactions,
);
} else {
// The reaction is new
cache[index] = message.copyWith(
reactions: [
...message.reactions,
Reaction(
[],
emoji,
true,
),
],
);
}
forceUpdateUI();
MoxplatformPlugin.handler.getDataSender().sendData(
AddReactionToMessageCommand(
messageId: message.id,
emoji: emoji,
conversationJid: conversationJid,
),
awaitable: false,
);
}
/// Remove the reaction [emoji] from the message at index [index].
void removeReaction(int index, String emoji) {
final message = cache[index];
final reactionIndex = message.reactions.indexWhere(
(Reaction r) => r.emoji == emoji,
);
assert(reactionIndex >= 0, 'The reaction must be found');
final reactions = List<Reaction>.from(message.reactions);
if (message.reactions[reactionIndex].senders.isEmpty) {
reactions.removeAt(reactionIndex);
} else {
reactions[reactionIndex] = reactions[reactionIndex].copyWith(
reactedBySelf: false,
);
}
cache[index] = cache[index].copyWith(
reactions: reactions,
);
forceUpdateUI();
MoxplatformPlugin.handler.getDataSender().sendData(
RemoveReactionFromMessageCommand(
messageId: message.id,
emoji: emoji,
conversationJid: conversationJid,
),
awaitable: false,
);
}
/// Send the sticker identified by the Sticker pack [packId] and [hashKey].
void sendSticker(String packId, String hashKey) {
MoxplatformPlugin.handler.getDataSender().sendData( MoxplatformPlugin.handler.getDataSender().sendData(
SendStickerCommand( SendStickerCommand(
stickerPackId: packId, sticker: sticker,
stickerHashKey: hashKey,
recipient: conversationJid, recipient: conversationJid,
quotes: _quotedMessage,
), ),
awaitable: false, awaitable: false,
); );
// Close the picker // Remove a possible quote
togglePickerVisibility(false); removeQuote();
} }
Future<void> sendMessage(bool encrypted) async { Future<void> sendMessage(bool encrypted) async {
@@ -444,7 +391,6 @@ class BidirectionalConversationController
TextFieldData( TextFieldData(
messageBody.isEmpty, messageBody.isEmpty,
message, message,
_pickerVisible,
), ),
); );
} }
@@ -456,7 +402,6 @@ class BidirectionalConversationController
TextFieldData( TextFieldData(
messageBody.isEmpty, messageBody.isEmpty,
null, null,
_pickerVisible,
), ),
); );
} }
@@ -491,37 +436,104 @@ class BidirectionalConversationController
_sendButtonStreamController.add(conversation.defaultSendButtonState); _sendButtonStreamController.add(conversation.defaultSendButtonState);
} }
/// Toggles the visibility of the (emoji/sticker) picker Future<void> startAudioMessageRecording() async {
void togglePickerVisibility(bool handleKeyboard) { final status = await Permission.speech.status;
final newState = !_pickerVisible; if (status.isDenied) {
await Permission.speech.request();
if (handleKeyboard) { return;
if (newState) {
SystemChannels.textInput.invokeMethod('TextInput.hide');
} else {
SystemChannels.textInput.invokeMethod('TextInput.show');
}
} }
_pickerVisible = newState; _recordingAudioMessageStreamController.add(
_pickerVisibleStreamController.add(newState); const RecordingData(
_textFieldDataStreamController.add( true,
TextFieldData( false,
messageBody.isEmpty, ),
_quotedMessage, );
newState, _sendButtonStreamController.add(conversation.SendButtonState.hidden);
final now = DateTime.now();
_recordingStart = now;
final tempDir = await getTemporaryDirectory();
final timestamp =
'${now.year}${now.month}${now.day}${now.hour}${now.minute}${now.second}';
final tempFile = path.join(tempDir.path, 'audio_$timestamp.aac');
await _audioRecorder.start(
path: tempFile,
);
}
void lockAudioMessageRecording() {
_recordingAudioMessageStreamController.add(
const RecordingData(
true,
true,
), ),
); );
} }
/// React to a onWillPop callback. Future<void> cancelAudioMessageRecording() async {
bool handlePop() { Vibrate.feedback(FeedbackType.heavy);
if (_pickerVisible) { _recordingAudioMessageStreamController.add(
togglePickerVisibility(false); const RecordingData(
return false; false,
false,
),
);
_sendButtonStreamController.add(conversation.defaultSendButtonState);
_recordingStart = null;
final file = await _audioRecorder.stop();
unawaited(File(file!).delete());
}
Future<void> endAudioMessageRecording() async {
_recordingAudioMessageStreamController.add(
const RecordingData(
false,
false,
),
);
_sendButtonStreamController.add(conversation.defaultSendButtonState);
if (_recordingStart == null) {
return;
} }
return true; Vibrate.feedback(FeedbackType.heavy);
final file = await _audioRecorder.stop();
final now = DateTime.now();
if (now.difference(_recordingStart!).inSeconds < 1) {
_recordingStart = null;
unawaited(File(file!).delete());
await Fluttertoast.showToast(
msg: t.warnings.conversation.holdForLonger,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return;
}
// Reset the recording timestamp
_recordingStart = null;
// Handle something unexpected
if (file == null) {
await Fluttertoast.showToast(
msg: t.errors.conversation.audioRecordingError,
gravity: ToastGravity.SNACKBAR,
toastLength: Toast.LENGTH_SHORT,
);
return;
}
// Send the file
await MoxplatformPlugin.handler.getDataSender().sendData(
SendFilesCommand(
paths: [file],
recipients: [conversationJid],
),
awaitable: false,
);
} }
/// React to app livecycle changes /// React to app livecycle changes
@@ -533,11 +545,16 @@ class BidirectionalConversationController
@override @override
void dispose() { void dispose() {
super.dispose(); // Reset the singleton
BidirectionalConversationController.currentController = null; BidirectionalConversationController.currentController = null;
// Dispose of controllers
_textController.dispose(); _textController.dispose();
_keyboardVisibilitySubscription.cancel(); _audioRecorder.dispose();
// Tell the contact that we're gone
_updateChatState(ChatState.gone); _updateChatState(ChatState.gone);
super.dispose();
} }
} }

View File

@@ -3,11 +3,11 @@ import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/constants.dart'; import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.dart'; import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart'; import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
class BidirectionalSharedMediaController class BidirectionalSharedMediaController
extends BidirectionalController<SharedMedium> { extends BidirectionalController<Message> {
BidirectionalSharedMediaController(this.conversationJid) BidirectionalSharedMediaController(this.conversationJid)
: assert( : assert(
BidirectionalSharedMediaController.currentController == null, BidirectionalSharedMediaController.currentController == null,
@@ -24,11 +24,12 @@ class BidirectionalSharedMediaController
/// BidirectionalConversationController at a time. /// BidirectionalConversationController at a time.
static BidirectionalSharedMediaController? currentController; static BidirectionalSharedMediaController? currentController;
/// The JID of the conversation we want to get shared media of.
final String conversationJid; final String conversationJid;
@override @override
Future<List<SharedMedium>> fetchOlderDataImpl( Future<List<Message>> fetchOlderDataImpl(
SharedMedium? oldestElement, Message? oldestElement,
) async { ) async {
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData( final result = await MoxplatformPlugin.handler.getDataSender().sendData(
@@ -37,14 +38,14 @@ class BidirectionalSharedMediaController
timestamp: oldestElement?.timestamp, timestamp: oldestElement?.timestamp,
olderThan: true, olderThan: true,
), ),
) as PagedSharedMediaResultEvent; ) as PagedMessagesResultEvent;
return result.media; return result.messages;
} }
@override @override
Future<List<SharedMedium>> fetchNewerDataImpl( Future<List<Message>> fetchNewerDataImpl(
SharedMedium? newestElement, Message? newestElement,
) async { ) async {
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData( final result = await MoxplatformPlugin.handler.getDataSender().sendData(
@@ -53,9 +54,9 @@ class BidirectionalSharedMediaController
timestamp: newestElement?.timestamp, timestamp: newestElement?.timestamp,
olderThan: false, olderThan: false,
), ),
) as PagedSharedMediaResultEvent; ) as PagedMessagesResultEvent;
return result.media; return result.messages;
} }
@override @override

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:better_open_file/better_open_file.dart'; import 'package:better_open_file/better_open_file.dart';
import 'package:cryptography/cryptography.dart'; import 'package:cryptography/cryptography.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_image_compress/flutter_image_compress.dart'; import 'package:flutter_image_compress/flutter_image_compress.dart';
@@ -398,3 +399,47 @@ Future<void> openFile(String path) async {
); );
} }
} }
/// Opens a modal bottom sheet with an emoji picker. Resolves to the picked emoji,
/// if one was picked. If the picker was dismissed, resolves to null.
Future<String?> pickEmoji(BuildContext context, {bool pop = true}) async {
final emoji = await showModalBottomSheet<String>(
context: context,
// TODO(PapaTutuWawa): Move this to the theme
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: radiusLarge,
topRight: radiusLarge,
),
),
builder: (context) => Padding(
padding: const EdgeInsets.only(top: 12),
child: EmojiPicker(
onEmojiSelected: (_, emoji) {
// ignore: use_build_context_synchronously
Navigator.of(context).pop(emoji.emoji);
},
//height: pickerHeight,
config: Config(
bgColor: Theme.of(context).scaffoldBackgroundColor,
),
),
),
);
if (pop) {
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
}
return emoji;
}
/// Compute the current position of the widget with the global key [key].
Rect getWidgetPositionOnScreen(GlobalKey key) {
// (See https://stackoverflow.com/questions/50316219/how-to-get-widgets-absolute-coordinates-on-a-screen-in-flutter/58788092#58788092)
final renderObject = key.currentContext!.findRenderObject()!;
final translation = renderObject.getTransformTo(null).getTranslation();
final offset = Offset(translation.x, translation.y);
return renderObject.paintBounds.shift(offset);
}

View File

@@ -40,7 +40,7 @@ class AddContactPageState extends State<AddContactPage> {
return true; return true;
}, },
child: Scaffold( child: Scaffold(
appBar: BorderlessTopbar.simple(t.pages.addcontact.title), appBar: BorderlessTopbar.title(t.pages.addcontact.title),
body: Column( body: Column(
children: [ children: [
Visibility( Visibility(

View File

@@ -94,38 +94,35 @@ class BlocklistPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<BlocklistBloc, BlocklistState>( return BlocBuilder<BlocklistBloc, BlocklistState>(
builder: (context, state) => Scaffold( builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple( appBar: BorderlessTopbar.title(
t.pages.blocklist.title, t.pages.blocklist.title,
extra: [ trailing: PopupMenuButton(
Expanded(child: Container()), onSelected: (BlocklistOptions result) async {
PopupMenuButton( if (result == BlocklistOptions.unblockAll) {
onSelected: (BlocklistOptions result) async { final result = await showConfirmationDialog(
if (result == BlocklistOptions.unblockAll) { t.pages.blocklist.unblockAllConfirmTitle,
final result = await showConfirmationDialog( t.pages.blocklist.unblockAllConfirmBody,
t.pages.blocklist.unblockAllConfirmTitle, context,
t.pages.blocklist.unblockAllConfirmBody, );
context,
);
if (result) { if (result) {
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
context.read<BlocklistBloc>().add(UnblockedAllEvent()); context.read<BlocklistBloc>().add(UnblockedAllEvent());
// ignore: use_build_context_synchronously // ignore: use_build_context_synchronously
Navigator.of(context).pop(); Navigator.of(context).pop();
}
} }
}, }
icon: const Icon(Icons.more_vert), },
itemBuilder: (BuildContext context) => [ icon: const Icon(Icons.more_vert),
PopupMenuItem( itemBuilder: (BuildContext context) => [
enabled: state.blocklist.isNotEmpty, PopupMenuItem(
value: BlocklistOptions.unblockAll, enabled: state.blocklist.isNotEmpty,
child: Text(t.pages.blocklist.unblockAll), value: BlocklistOptions.unblockAll,
), child: Text(t.pages.blocklist.unblockAll),
], ),
) ],
], ),
), ),
body: _buildListView(state), body: _buildListView(state),
), ),

View File

@@ -1,4 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart'; import 'package:flutter_speed_dial/flutter_speed_dial.dart';
@@ -11,29 +10,46 @@ import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/controller/conversation_controller.dart'; import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/blink.dart'; import 'package:moxxyv2/ui/pages/conversation/blink.dart';
import 'package:moxxyv2/ui/pages/conversation/keyboard_dodging.dart';
import 'package:moxxyv2/ui/pages/conversation/timer.dart'; import 'package:moxxyv2/ui/pages/conversation/timer.dart';
import 'package:moxxyv2/ui/service/data.dart'; import 'package:moxxyv2/ui/service/data.dart';
import 'package:moxxyv2/ui/theme.dart'; import 'package:moxxyv2/ui/theme.dart';
import 'package:moxxyv2/ui/widgets/chat/message.dart'; import 'package:moxxyv2/ui/widgets/chat/message.dart';
import 'package:moxxyv2/ui/widgets/combined_picker.dart';
import 'package:moxxyv2/ui/widgets/textfield.dart'; import 'package:moxxyv2/ui/widgets/textfield.dart';
import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart';
class _TextFieldIconButton extends StatelessWidget { class _TextFieldIconButton extends StatelessWidget {
const _TextFieldIconButton(this.icon, this.onTap); const _TextFieldIconButton({
final void Function() onTap; required this.keyboardController,
final IconData icon; required this.tabController,
required this.textfieldFocusNode,
});
final KeyboardReplacerController keyboardController;
final TabController tabController;
final FocusNode textfieldFocusNode;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
onTap: onTap, onTap: () {
keyboardController.toggleWidget(context, textfieldFocusNode);
},
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon( child: StreamBuilder<KeyboardReplacerData>(
icon, stream: keyboardController.stream,
size: 24, initialData: keyboardController.currentData,
color: primaryColor, builder: (context, snapshot) => Icon(
snapshot.data!.showWidget
? Icons.keyboard
: (tabController.index == 0
? Icons.insert_emoticon
: PhosphorIcons.stickerBold),
size: 24,
color: primaryColor,
),
), ),
), ),
); );
@@ -41,7 +57,13 @@ class _TextFieldIconButton extends StatelessWidget {
} }
class _TextFieldRecordButton extends StatelessWidget { class _TextFieldRecordButton extends StatelessWidget {
const _TextFieldRecordButton(); const _TextFieldRecordButton({
required this.conversationController,
required this.keyboardController,
});
final BidirectionalConversationController conversationController;
final KeyboardReplacerController keyboardController;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -50,15 +72,15 @@ class _TextFieldRecordButton extends StatelessWidget {
axis: Axis.vertical, axis: Axis.vertical,
onDragStarted: () { onDragStarted: () {
Vibrate.feedback(FeedbackType.heavy); Vibrate.feedback(FeedbackType.heavy);
context.read<ConversationBloc>().add(
SendButtonDragStartedEvent(), conversationController.startAudioMessageRecording();
); keyboardController.hideWidget();
dismissSoftKeyboard(context);
}, },
onDraggableCanceled: (_, __) { onDraggableCanceled: (_, __) {
Vibrate.feedback(FeedbackType.heavy); Vibrate.feedback(FeedbackType.heavy);
context.read<ConversationBloc>().add(
SendButtonDragEndedEvent(), conversationController.endAudioMessageRecording();
);
}, },
childWhenDragging: const SizedBox(), childWhenDragging: const SizedBox(),
feedback: SizedBox( feedback: SizedBox(
@@ -88,26 +110,37 @@ class _TextFieldRecordButton extends StatelessWidget {
} }
} }
class ConversationBottomRow extends StatefulWidget { class ConversationInput extends StatefulWidget {
const ConversationBottomRow( const ConversationInput({
this.tabController, required this.keyboardController,
this.focusNode, required this.conversationController,
this.conversationController, required this.tabController,
this.speedDialValueNotifier, { required this.speedDialValueNotifier,
required this.isEncrypted,
required this.textfieldFocusNode,
super.key, super.key,
}); });
final TabController tabController;
final FocusNode focusNode; final KeyboardReplacerController keyboardController;
final ValueNotifier<bool> speedDialValueNotifier;
final BidirectionalConversationController conversationController; final BidirectionalConversationController conversationController;
final TabController tabController;
final ValueNotifier<bool> speedDialValueNotifier;
final bool isEncrypted;
final FocusNode textfieldFocusNode;
@override @override
ConversationBottomRowState createState() => ConversationBottomRowState(); ConversationInputState createState() => ConversationInputState();
} }
class ConversationBottomRowState extends State<ConversationBottomRow> { class ConversationInputState extends State<ConversationInput> {
IconData _getSendButtonIcon(SendButtonState state) { IconData _getSendButtonIcon(SendButtonState state) {
switch (state) { switch (state) {
case SendButtonState.hidden:
case SendButtonState.multi: case SendButtonState.multi:
return Icons.add; return Icons.add;
case SendButtonState.send: case SendButtonState.send:
@@ -117,314 +150,212 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
} }
} }
IconData _getPickerIcon() {
if (widget.tabController.index == 0) {
return Icons.insert_emoticon;
}
return PhosphorIcons.stickerBold;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( return ColoredBox(
clipBehavior: Clip.none, color: Colors.black45,
children: [ child: Padding(
Positioned( padding: const EdgeInsets.all(8),
child: ColoredBox( child: Stack(
color: Colors.transparent, children: [
child: Column( Row(
children: [ children: [
Expanded(
child: StreamBuilder<TextFieldData>(
initialData: const TextFieldData(
true,
null,
),
stream: widget.conversationController.textFieldDataStream,
builder: (context, snapshot) {
return CustomTextField(
backgroundColor: Theme.of(context)
.extension<MoxxyThemeData>()!
.conversationTextFieldColor,
textColor: Theme.of(context)
.extension<MoxxyThemeData>()!
.conversationTextFieldTextColor,
maxLines: 5,
hintText: t.pages.conversation.messageHint,
hintTextColor: Theme.of(context)
.extension<MoxxyThemeData>()!
.conversationTextFieldHintTextColor,
isDense: true,
contentPadding: textfieldPaddingConversation,
fontSize: textFieldFontSizeConversation,
cornerRadius: textfieldRadiusConversation,
controller:
widget.conversationController.textController,
topWidget: snapshot.data!.quotedMessage != null
? buildQuoteMessageWidget(
snapshot.data!.quotedMessage!,
isSent(
snapshot.data!.quotedMessage!,
GetIt.I.get<UIDataService>().ownJid!,
),
textfieldQuotedMessageRadius,
textfieldQuotedMessageRadius,
resetQuote:
widget.conversationController.removeQuote,
)
: null,
focusNode: widget.textfieldFocusNode,
//shouldSummonKeyboard: () => !snapshot.data!.pickerVisible,
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 8),
child: _TextFieldIconButton(
keyboardController: widget.keyboardController,
tabController: widget.tabController,
textfieldFocusNode: widget.textfieldFocusNode,
),
),
prefixIconConstraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
suffixIcon: snapshot.data!.isBodyEmpty &&
snapshot.data!.quotedMessage == null
? Padding(
padding: const EdgeInsets.only(right: 8),
child: _TextFieldRecordButton(
conversationController:
widget.conversationController,
keyboardController: widget.keyboardController,
),
)
: null,
suffixIconConstraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
);
},
),
),
Padding( Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.only(left: 16),
child: BlocBuilder<ConversationBloc, ConversationState>( child: SizedBox(
buildWhen: (prev, next) => width: 45,
prev.isRecording != next.isRecording, height: 45,
builder: (context, state) => Row( child: StreamBuilder<SendButtonState>(
children: [ initialData: defaultSendButtonState,
Expanded( stream: widget.conversationController.sendButtonStream,
child: StreamBuilder<TextFieldData>( builder: (context, snapshot) => IgnorePointer(
initialData: const TextFieldData( ignoring: snapshot.data! == SendButtonState.hidden,
true, child: AnimatedOpacity(
null, opacity:
false, snapshot.data! == SendButtonState.hidden ? 0 : 1,
), duration: const Duration(milliseconds: 150),
stream: widget child: SpeedDial(
.conversationController.textFieldDataStream, icon: _getSendButtonIcon(snapshot.data!),
builder: (context, snapshot) { backgroundColor: primaryColor,
return CustomTextField( foregroundColor: Colors.white,
backgroundColor: Theme.of(context) children: [
.extension<MoxxyThemeData>()! SpeedDialChild(
.conversationTextFieldColor, child: const Icon(Icons.image),
textColor: Theme.of(context) onTap: () {
.extension<MoxxyThemeData>()! context.read<ConversationBloc>().add(
.conversationTextFieldTextColor, ImagePickerRequestedEvent(),
maxLines: 5, );
hintText: t.pages.conversation.messageHint, },
hintTextColor: Theme.of(context) backgroundColor: primaryColor,
.extension<MoxxyThemeData>()! foregroundColor: Colors.white,
.conversationTextFieldHintTextColor, label: t.pages.conversation.sendImages,
isDense: true, ),
contentPadding: textfieldPaddingConversation, SpeedDialChild(
fontSize: textFieldFontSizeConversation, child: const Icon(Icons.file_present),
cornerRadius: textfieldRadiusConversation, onTap: () {
controller: widget context.read<ConversationBloc>().add(
.conversationController.textController, FilePickerRequestedEvent(),
topWidget: snapshot.data!.quotedMessage != null );
? buildQuoteMessageWidget( },
snapshot.data!.quotedMessage!, backgroundColor: primaryColor,
isSent( foregroundColor: Colors.white,
snapshot.data!.quotedMessage!, label: t.pages.conversation.sendFiles,
GetIt.I.get<UIDataService>().ownJid!, ),
), SpeedDialChild(
resetQuote: widget child: const Icon(Icons.photo_camera),
.conversationController.removeQuote, onTap: () {
) showNotImplementedDialog(
: null, 'taking photos',
focusNode: widget.focusNode, context,
shouldSummonKeyboard: () => );
!snapshot.data!.pickerVisible, },
prefixIcon: IntrinsicWidth( backgroundColor: primaryColor,
child: Row( foregroundColor: Colors.white,
children: [ label: t.pages.conversation.takePhotos,
Padding( ),
padding: const EdgeInsets.only(left: 8), ],
child: _TextFieldIconButton( openCloseDial: widget.speedDialValueNotifier,
snapshot.data!.pickerVisible onPress: () {
? Icons.keyboard switch (snapshot.data!) {
: _getPickerIcon(), case SendButtonState.cancelCorrection:
() { widget.conversationController
widget.conversationController .endMessageEditing();
.togglePickerVisibility(true); return;
}, case SendButtonState.send:
), widget.conversationController.sendMessage(
), widget.isEncrypted,
], );
), return;
), case SendButtonState.multi:
prefixIconConstraints: const BoxConstraints( widget.speedDialValueNotifier.value =
minWidth: 24, !widget.speedDialValueNotifier.value;
minHeight: 24, return;
), case SendButtonState.hidden:
suffixIcon: snapshot.data!.isBodyEmpty && return;
snapshot.data!.quotedMessage == null }
? IntrinsicWidth(
child: Row(
children: const [
Padding(
padding:
EdgeInsets.only(right: 8),
child: _TextFieldRecordButton(),
),
],
),
)
: null,
suffixIconConstraints: const BoxConstraints(
minWidth: 24,
minHeight: 24,
),
);
}, },
), ),
), ),
Padding( ),
padding: const EdgeInsets.only(left: 8),
child: AnimatedOpacity(
opacity: state.isRecording ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: IgnorePointer(
ignoring: state.isRecording,
child: SizedBox(
height: 45,
width: 45,
child: StreamBuilder<SendButtonState>(
initialData: defaultSendButtonState,
stream: widget
.conversationController.sendButtonStream,
builder: (context, snapshot) {
return SpeedDial(
icon: _getSendButtonIcon(snapshot.data!),
backgroundColor: primaryColor,
foregroundColor: Colors.white,
children: [
SpeedDialChild(
child: const Icon(Icons.image),
onTap: () {
context
.read<ConversationBloc>()
.add(
ImagePickerRequestedEvent(),
);
},
backgroundColor: primaryColor,
foregroundColor: Colors.white,
label:
t.pages.conversation.sendImages,
),
SpeedDialChild(
child: const Icon(Icons.file_present),
onTap: () {
context
.read<ConversationBloc>()
.add(
FilePickerRequestedEvent(),
);
},
backgroundColor: primaryColor,
foregroundColor: Colors.white,
label: t.pages.conversation.sendFiles,
),
SpeedDialChild(
child: const Icon(Icons.photo_camera),
onTap: () {
showNotImplementedDialog(
'taking photos',
context,
);
},
backgroundColor: primaryColor,
foregroundColor: Colors.white,
label:
t.pages.conversation.takePhotos,
),
],
openCloseDial:
widget.speedDialValueNotifier,
onPress: () {
switch (snapshot.data!) {
case SendButtonState.cancelCorrection:
widget.conversationController
.endMessageEditing();
return;
case SendButtonState.send:
widget.conversationController
.sendMessage(
state.conversation!.encrypted,
);
return;
case SendButtonState.multi:
widget.speedDialValueNotifier
.value =
!widget.speedDialValueNotifier
.value;
return;
}
},
);
},
),
),
),
),
),
],
),
),
),
StreamBuilder<bool>(
initialData: false,
stream: widget.conversationController.pickerVisibleStream,
builder: (context, snapshot) => Offstage(
offstage: !snapshot.data!,
child: CombinedPicker(
tabController: widget.tabController,
onEmojiTapped: (emoji) {
final selection = widget
.conversationController.textController.selection;
final baseOffset = max(selection.baseOffset, 0);
final extentOffset = max(selection.extentOffset, 0);
final prefix = widget.conversationController.messageBody
.substring(0, baseOffset);
final suffix = widget.conversationController.messageBody
.substring(extentOffset);
final newText = '$prefix${emoji.emoji}$suffix';
final newValue =
baseOffset + emoji.emoji.codeUnits.length;
widget.conversationController.textController
..text = newText
..selection = TextSelection(
baseOffset: newValue,
extentOffset: newValue,
);
},
onBackspaceTapped: () {
// Taken from https://github.com/Fintasys/emoji_picker_flutter/blob/master/lib/src/emoji_picker.dart#L183
final text = widget.conversationController.messageBody;
final selection = widget
.conversationController.textController.selection;
final cursorPosition = widget.conversationController
.textController.selection.base.offset;
if (cursorPosition < 0) {
return;
}
final newTextBeforeCursor = selection
.textBefore(text)
.characters
.skipLast(1)
.toString();
widget.conversationController.textController
..text = newTextBeforeCursor
..selection = TextSelection.fromPosition(
TextPosition(offset: newTextBeforeCursor.length),
);
},
onStickerTapped: (sticker, pack) {
widget.conversationController.sendSticker(
pack.id,
sticker.hashKey,
);
},
), ),
), ),
), ),
], ],
), ),
), Positioned(
), top: 0,
Positioned( bottom: 0,
left: 8, left: 0,
bottom: 8, right: 45 + 16,
right: 61, child: StreamBuilder<RecordingData>(
child: BlocBuilder<ConversationBloc, ConversationState>( initialData: const RecordingData(
buildWhen: (prev, next) => prev.isRecording != next.isRecording, false,
builder: (context, state) { false,
return AnimatedOpacity( ),
opacity: state.isRecording ? 1 : 0, stream:
duration: const Duration(milliseconds: 300), widget.conversationController.recordingAudioMessageStream,
child: IgnorePointer( builder: (context, snapshot) => IgnorePointer(
ignoring: !state.isRecording, ignoring: !snapshot.data!.isRecording,
child: SizedBox( child: AnimatedOpacity(
height: textFieldFontSizeConversation + 2 * 12 + 2, opacity: snapshot.data!.isRecording ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context)
.extension<MoxxyThemeData>()!
.conversationTextFieldColor,
borderRadius: borderRadius:
BorderRadius.circular(textfieldRadiusConversation), BorderRadius.circular(textfieldRadiusConversation),
color: Theme.of(context).scaffoldBackgroundColor,
), ),
// NOTE: We use a comprehension here so that the widget gets child: Padding(
// created and destroyed to prevent the timer from running padding: const EdgeInsets.only(left: 16),
// until the user closes the page. child: Align(
child: state.isRecording alignment: Alignment.centerLeft,
? const Align( child: snapshot.data!.isRecording
alignment: Alignment.centerLeft, ? const TimerWidget()
child: Padding( : const SizedBox(),
padding: EdgeInsets.only(left: 16), ),
child: TimerWidget(), ),
),
)
: null,
), ),
), ),
), ),
); ),
}, ),
), ],
), ),
], ),
); );
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,250 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:keyboard_height_plugin/keyboard_height_plugin.dart';
import 'package:moxxyv2/ui/helpers.dart';
/// A triple of data for the child widget wrapper widget.
class KeyboardReplacerData {
const KeyboardReplacerData(
this.visible,
this.height,
this.showWidget,
);
/// Flag indicating whether the keyboard is visible or not.
final bool visible;
/// The height of the keyboard.
final double height;
/// Flag indicating whether the widget should be shown or not.
final bool showWidget;
}
/// A controller to interact with the child wrapper widget.
class KeyboardReplacerController {
KeyboardReplacerController() {
_keyboardVisible = _keyboardController.isVisible;
_keyboardVisibilitySubscription =
_keyboardController.onChange.listen((visible) {
// Only update when the state actually changed
if (visible == _keyboardVisible) {
return;
}
if (visible) {
_widgetVisible = false;
}
_keyboardVisible = visible;
_streamController.add(
KeyboardReplacerData(
visible,
_keyboardHeight,
_widgetVisible,
),
);
});
_keyboardHeightPlugin.onKeyboardHeightChanged((height) {
// Only update when the height actually changed
if (height == 0 || height == _keyboardHeight) return;
_keyboardHeight = height;
_streamController.add(
KeyboardReplacerData(
_keyboardVisible,
height,
_widgetVisible,
),
);
});
}
/// State of the child widget's visibility.
bool _widgetVisible = false;
/// Data for keeping track of the keyboard visibility.
final KeyboardVisibilityController _keyboardController =
KeyboardVisibilityController();
late final StreamSubscription<bool> _keyboardVisibilitySubscription;
/// Flag indicating whether the keyboard is currently visible or not.
late bool _keyboardVisible;
/// Data for keeping track of the keyboard height.
final KeyboardHeightPlugin _keyboardHeightPlugin = KeyboardHeightPlugin();
/// The currently tracked keyboard height.
/// NOTE: The value is a random keyboard height I got on my test device.
// TODO(Unknown): Maybe make this platform specific.
double _keyboardHeight = 260;
/// The stream for building the child widget wrapper.
final StreamController<KeyboardReplacerData> _streamController =
StreamController<KeyboardReplacerData>.broadcast();
Stream<KeyboardReplacerData> get stream => _streamController.stream;
/// Get the currently tracked data.
KeyboardReplacerData get currentData => KeyboardReplacerData(
_keyboardVisible,
_keyboardHeight,
_widgetVisible,
);
void dispose() {
_keyboardVisibilitySubscription.cancel();
_keyboardHeightPlugin.dispose();
}
/// Show the child widget in the child wrapper. If the soft-keyboard is currently
/// visible, dismiss it.
void showWidget(BuildContext context) {
_widgetVisible = true;
if (_keyboardVisible) {
dismissSoftKeyboard(context);
}
// Notify the child widget wrapper
_streamController.add(
KeyboardReplacerData(
false,
_keyboardHeight,
true,
),
);
}
/// Hide the child widget. If [summonKeyboard] is true, then the soft-keyboard is
/// summoned.
void hideWidget({bool summonKeyboard = false}) {
_widgetVisible = false;
_streamController.add(
KeyboardReplacerData(
summonKeyboard,
_keyboardHeight,
false,
),
);
}
/// Toggle between (Widget visible, keyboard hidden) and (Widget hidden, keyboard shown).
/// Requires the FocusNode [focusNode] of the Textfield.
void toggleWidget(BuildContext context, FocusNode focusNode) {
if (_widgetVisible) {
hideWidget(summonKeyboard: true);
focusNode.requestFocus();
} else {
showWidget(context);
}
}
}
/// A widget for wrapping a given child that should be switching places with the
/// soft-keyboard.
class KeyboardReplacerWidget extends StatelessWidget {
const KeyboardReplacerWidget(this.controller, this.child, {super.key});
/// A controller that feeds this widget with data.
final KeyboardReplacerController controller;
/// The child to show or not show.
final Widget child;
@override
Widget build(BuildContext context) {
return StreamBuilder<KeyboardReplacerData>(
initialData: controller.currentData,
stream: controller.stream,
builder: (context, snapshot) {
return SizedBox(
height: snapshot.data!.visible || snapshot.data!.showWidget
? snapshot.data!.height
: 0,
width: MediaQuery.of(context).size.width,
child: Offstage(
offstage: !snapshot.data!.showWidget,
child: child,
),
);
},
);
}
}
/// An alternative to the regular Scaffold that allows keyboard dodging with a widget
/// that switches places with the soft-keyboard.
class KeyboardReplacerScaffold extends StatelessWidget {
const KeyboardReplacerScaffold({
required this.controller,
required this.children,
required this.appbar,
required this.keyboardWidget,
required this.background,
required this.extraStackChildren,
super.key,
});
/// The KeyboardReplacerController for the keyboard "dodging".
final KeyboardReplacerController controller;
/// The body of the scaffold.
final List<Widget> children;
/// The widget that can switch places with the soft-keyboard.
final Widget keyboardWidget;
/// The app bar.
final Widget appbar;
/// The background of the "scaffold". Useful for displaying a background image.
final Widget background;
final List<Widget>? extraStackChildren;
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
final headerHeight = mq.viewPadding.top;
return Stack(
children: [
// The background should not move when we dodge the keyboard
Positioned(
// Do not leak under the system UI
top: headerHeight,
left: 0,
right: 0,
bottom: 0,
child: background,
),
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: SafeArea(
bottom: false,
child: Material(
color: Colors.transparent,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
appbar,
...children,
KeyboardReplacerWidget(
controller,
keyboardWidget,
),
],
),
),
),
),
if (extraStackChildren != null) ...extraStackChildren!,
],
);
}
}

View File

@@ -0,0 +1,337 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/warning_types.dart';
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
import 'package:moxxyv2/ui/widgets/context_menu.dart';
/// A data "packet" to describe the selected message.
class SelectedMessageData {
const SelectedMessageData(
this.message,
this.isChatEncrypted,
this.sentBySelf,
this.originalPosition,
this.requiredYOffset,
this.start,
this.between,
this.end,
);
/// The message content.
final Message? message;
/// Flag indicating whether the current chat is encrypted or not.
final bool isChatEncrypted;
/// Flag indicating whether [message] was sent by ourselves or not.
final bool sentBySelf;
/// The original screen position of the message.
final Offset originalPosition;
/// The offset that is required for the animation to work.
final double requiredYOffset;
/// Flags for the message's corners.
final bool start;
final bool between;
final bool end;
}
/// A controller class for managing a [SelectedMessage] widget.
class SelectedMessageController {
SelectedMessageController(this._controller, this.animation) {
_controller.addListener(_onAnimationChanged);
}
/// Provide the stream to the widget.
final StreamController<SelectedMessageData> _streamController =
StreamController<SelectedMessageData>.broadcast();
Stream<SelectedMessageData> get stream => _streamController.stream;
/// The [AnimationController] and the animation. Used for detecting the end of the
/// animation and providing data to the widget.
final AnimationController _controller;
final Animation<double> animation;
/// Flag indicating whether we should reset [state] when the [AnimationController]'s
/// value hits 0.
bool _shouldClearMessage = false;
/// The current state of what the widget is displaying.
SelectedMessageData state = const SelectedMessageData(
null,
false,
false,
Offset.zero,
0,
false,
false,
false,
);
void _onAnimationChanged() {
if (_controller.value == 0 && _shouldClearMessage) {
// Prevent the widget from constantly being rendered and layouted.
_streamController.add(
const SelectedMessageData(
null,
false,
false,
Offset.zero,
0,
false,
false,
false,
),
);
_shouldClearMessage = false;
} else if (_controller.value == 1) {
_shouldClearMessage = true;
}
}
/// Mark a message as selected and start the animation.
void selectMessage(SelectedMessageData data) {
state = data;
_streamController.add(data);
_controller.forward();
}
/// Dismiss the message selection
void dismiss() {
_controller.reverse();
}
}
class SelectedMessage extends StatelessWidget {
const SelectedMessage(this.controller, {super.key});
/// The controller for managing the state and the animation.
final SelectedMessageController controller;
@override
Widget build(BuildContext context) {
return StreamBuilder<SelectedMessageData>(
initialData: controller.state,
stream: controller.stream,
builder: (context, snapshot) {
if (snapshot.data!.message == null) {
return const SizedBox();
}
return AnimatedBuilder(
animation: controller.animation,
builder: (context, child) {
return Positioned(
left: snapshot.data!.originalPosition.dx,
top: snapshot.data!.originalPosition.dy +
controller.animation.value * snapshot.data!.requiredYOffset,
child: IgnorePointer(
child: child,
),
);
},
child: RawChatBubble(
snapshot.data!.message!,
MediaQuery.of(context).size.width * 0.6,
snapshot.data!.sentBySelf,
snapshot.data!.isChatEncrypted,
snapshot.data!.start,
snapshot.data!.between,
snapshot.data!.end,
),
);
},
);
}
}
class SelectedMessageContextMenu extends StatelessWidget {
const SelectedMessageContextMenu({
required this.selectionController,
required this.conversationController,
super.key,
});
final SelectedMessageController selectionController;
final BidirectionalConversationController conversationController;
@override
Widget build(BuildContext context) {
return StreamBuilder<SelectedMessageData>(
stream: selectionController.stream,
initialData: selectionController.state,
builder: (context, snapshot) {
if (snapshot.data!.message == null) {
return const SizedBox();
}
final sentBySelf = snapshot.data!.sentBySelf;
final message = snapshot.data!.message!;
return AnimatedBuilder(
animation: selectionController.animation,
builder: (context, _) => Positioned(
left: sentBySelf ? null : 20,
right: sentBySelf ? 20 : null,
bottom: 20,
child: Opacity(
opacity: selectionController.animation.value,
child: ContextMenu(
children: [
if (message.isReactable)
ContextMenuItem(
icon: Icons.add_reaction,
text: t.pages.conversation.addReaction,
onPressed: () async {
final emoji = await pickEmoji(context, pop: false);
if (emoji != null) {
await MoxplatformPlugin.handler
.getDataSender()
.sendData(
AddReactionToMessageCommand(
messageId: message.id,
emoji: emoji,
conversationJid:
conversationController.conversationJid,
),
awaitable: false,
);
}
selectionController.dismiss();
},
),
if (message.canRetract(sentBySelf))
ContextMenuItem(
icon: Icons.delete,
text: t.pages.conversation.retract,
onPressed: () async {
final result = await showConfirmationDialog(
t.pages.conversation.retract,
t.pages.conversation.retractBody,
context,
);
if (result) {
conversationController
.retractMessage(message.originId!);
}
selectionController.dismiss();
},
),
// TODO(Unknown): Also allow correcting older messages
if (message.canEdit(sentBySelf) &&
GetIt.I
.get<ConversationBloc>()
.state
.conversation
?.lastMessage
?.id ==
message.id)
ContextMenuItem(
icon: Icons.edit,
text: t.pages.conversation.edit,
onPressed: () {
conversationController.beginMessageEditing(
message.body,
message.quotes,
message.id,
message.sid,
);
selectionController.dismiss();
},
),
if (message.errorMenuVisible)
ContextMenuItem(
icon: Icons.info_outline,
text: t.pages.conversation.showError,
onPressed: () {
showInfoDialog(
t.errors.conversation.messageErrorDialogTitle,
errorToTranslatableString(
message.errorType!,
),
context,
);
selectionController.dismiss();
},
),
if (message.hasWarning)
ContextMenuItem(
icon: Icons.warning,
text: t.pages.conversation.showWarning,
onPressed: () {
showInfoDialog(
'Warning',
warningToTranslatableString(
message.warningType!,
),
context,
);
selectionController.dismiss();
},
),
if (message.isCopyable)
ContextMenuItem(
icon: Icons.content_copy,
text: t.pages.conversation.copy,
onPressed: () {
// TODO(Unknown): Show a toast saying the message has been copied
Clipboard.setData(
ClipboardData(
text: message.body,
),
);
selectionController.dismiss();
},
),
if (message.isQuotable && message.conversationJid != '')
ContextMenuItem(
icon: Icons.forward,
text: t.pages.conversation.forward,
onPressed: () {
showNotImplementedDialog(
'sharing',
context,
);
selectionController.dismiss();
},
),
if (message.isQuotable)
ContextMenuItem(
icon: Icons.reply,
text: t.pages.conversation.quote,
onPressed: () {
conversationController.quoteMessage(message);
selectionController.dismiss();
},
),
],
),
),
),
);
},
);
}
}

View File

@@ -7,10 +7,10 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart'; import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile; import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/pages/conversation/helpers.dart'; import 'package:moxxyv2/ui/pages/conversation/helpers.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart'; import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
import 'package:moxxyv2/ui/widgets/contact_helper.dart'; import 'package:moxxyv2/ui/widgets/contact_helper.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart'; import 'package:moxxyv2/ui/widgets/topbar.dart';
@@ -18,12 +18,12 @@ enum ConversationOption { close, block }
enum EncryptionOption { omemo, none } enum EncryptionOption { omemo, none }
PopupMenuItem<dynamic> popupItemWithIcon( PopupMenuItem<T> popupItemWithIcon<T>(
dynamic value, T value,
String text, String text,
IconData icon, IconData icon,
) { ) {
return PopupMenuItem<dynamic>( return PopupMenuItem<T>(
value: value, value: value,
child: Row( child: Row(
children: [ children: [
@@ -39,26 +39,25 @@ PopupMenuItem<dynamic> popupItemWithIcon(
/// A custom version of the BorderlessTopbar to display the conversation topbar /// A custom version of the BorderlessTopbar to display the conversation topbar
/// as it should /// as it should
// TODO(PapaTutuWawa): The conversation title may overflow the Topbar
// TODO(Unknown): Maybe merge with BorderlessTopbar
class ConversationTopbar extends StatelessWidget class ConversationTopbar extends StatelessWidget
implements PreferredSizeWidget { implements PreferredSizeWidget {
const ConversationTopbar({super.key}); const ConversationTopbar({super.key});
@override @override
Size get preferredSize => const Size.fromHeight(60); Size get preferredSize =>
const Size.fromHeight(BorderlessTopbar.topbarPreferredHeight);
bool _shouldRebuild(ConversationState prev, ConversationState next) { bool _shouldRebuild(ConversationState prev, ConversationState next) {
return prev.conversation?.title != next.conversation?.title || return prev.conversation?.title != next.conversation?.title ||
prev.conversation?.avatarUrl != next.conversation?.avatarUrl || prev.conversation?.avatarUrl != next.conversation?.avatarUrl ||
prev.conversation?.chatState != next.conversation?.chatState || prev.conversation?.chatState != next.conversation?.chatState ||
prev.conversation?.jid != next.conversation?.jid || prev.conversation?.jid != next.conversation?.jid ||
prev.conversation?.encrypted != next.conversation?.encrypted || prev.conversation?.encrypted != next.conversation?.encrypted;
prev.conversation?.sharedMedia != next.conversation?.sharedMedia;
} }
Widget _buildChatState(ChatState state) { Widget _buildChatState(ChatState state) {
switch (state) { switch (state) {
case ChatState.composing:
case ChatState.paused: case ChatState.paused:
case ChatState.active: case ChatState.active:
return Text( return Text(
@@ -67,12 +66,9 @@ class ConversationTopbar extends StatelessWidget
color: Colors.green, color: Colors.green,
), ),
); );
case ChatState.composing:
// TODO(Unknown): Colors
return const TypingIndicatorWidget(Colors.black, Colors.white);
case ChatState.inactive: case ChatState.inactive:
case ChatState.gone: case ChatState.gone:
return Container(); return const SizedBox();
} }
} }
@@ -96,20 +92,20 @@ class ConversationTopbar extends StatelessWidget
buildWhen: _shouldRebuild, buildWhen: _shouldRebuild,
builder: (context, state) { builder: (context, state) {
final chatState = state.conversation?.chatState ?? ChatState.gone; final chatState = state.conversation?.chatState ?? ChatState.gone;
return SizedBox( return BorderlessTopbar(
width: MediaQuery.of(context).size.width, children: [
child: SafeArea( Expanded(
child: ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.only(
child: Flex( top: 8,
direction: Axis.horizontal, right: 8,
children: [ bottom: 8,
const BackButton(), ),
InkWell( child: InkWell(
onTap: () => _openProfile(context, state), onTap: () => _openProfile(context, state),
child: Hero( child: Stack(
children: [
Hero(
tag: 'conversation_profile_picture', tag: 'conversation_profile_picture',
child: Material( child: Material(
color: Colors.transparent, color: Colors.transparent,
@@ -126,139 +122,120 @@ class ConversationTopbar extends StatelessWidget
), ),
), ),
), ),
), AnimatedPositioned(
Expanded( duration: const Duration(milliseconds: 200),
child: InkWell( top: _isChatStateVisible(chatState) ? 0 : 10,
onTap: () => _openProfile(context, state), left: 60,
child: Stack( right: 0,
children: [ curve: Curves.easeInOutCubic,
AnimatedPositioned( child: RebuildOnContactIntegrationChange(
duration: const Duration(milliseconds: 200), builder: () => Text(
top: _isChatStateVisible(chatState) ? 0 : 10, state.conversation?.titleWithOptionalContact ?? '',
left: 0, style: const TextStyle(
right: 0, fontSize: fontsizeAppbar,
curve: Curves.easeInOutCubic,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
RebuildOnContactIntegrationChange(
builder: () => TopbarTitleText(
state.conversation
?.titleWithOptionalContact ??
'',
),
),
],
),
), ),
Positioned( overflow: TextOverflow.ellipsis,
left: 0, ),
right: 0,
bottom: 0,
child: AnimatedOpacity(
opacity:
_isChatStateVisible(chatState) ? 1.0 : 0.0,
curve: Curves.easeInOutCubic,
duration: const Duration(milliseconds: 100),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildChatState(chatState),
],
),
),
),
],
), ),
), ),
), Positioned(
if (state.conversation?.type != ConversationType.note) left: 25,
// ignore: implicit_dynamic_type right: 0,
PopupMenuButton( bottom: 0,
onSelected: (result) { child: AnimatedOpacity(
if (result == EncryptionOption.omemo && opacity: _isChatStateVisible(chatState) ? 1.0 : 0.0,
state.conversation!.encrypted == false) { curve: Curves.easeInOutCubic,
context duration: const Duration(milliseconds: 100),
.read<ConversationBloc>() child: Row(
.add(OmemoSetEvent(true)); mainAxisAlignment: MainAxisAlignment.center,
} else if (result == EncryptionOption.none && children: [
state.conversation!.encrypted == true) { _buildChatState(chatState),
context ],
.read<ConversationBloc>()
.add(OmemoSetEvent(false));
}
},
icon: (state.conversation?.encrypted ?? false)
? const Icon(Icons.lock)
: const Icon(Icons.lock_open),
itemBuilder: (BuildContext c) => [
popupItemWithIcon(
EncryptionOption.none,
t.pages.conversation.unencrypted,
Icons.lock_open,
), ),
popupItemWithIcon(
EncryptionOption.omemo,
t.pages.conversation.encrypted,
Icons.lock,
),
],
),
// ignore: implicit_dynamic_type
PopupMenuButton(
onSelected: (result) async {
switch (result) {
case ConversationOption.close:
{
final result = await showConfirmationDialog(
t.pages.conversation.closeChatConfirmTitle,
t.pages.conversation.closeChatConfirmSubtext,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<ConversationsBloc>().add(
ConversationClosedEvent(
state.conversation!.jid,
),
);
// Navigate back
// ignore: use_build_context_synchronously
context.read<NavigationBloc>().add(
PoppedRouteEvent(),
);
}
}
break;
case ConversationOption.block:
{
await blockJid(state.conversation!.jid, context);
}
break;
}
},
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext c) => [
popupItemWithIcon(
ConversationOption.close,
t.pages.conversation.closeChat,
Icons.close,
), ),
if (state.conversation?.type != ConversationType.note) ),
popupItemWithIcon( ],
ConversationOption.block, ),
t.pages.conversation.blockUser,
Icons.block,
)
],
),
],
), ),
), ),
), ),
), if (state.conversation?.type != ConversationType.note)
PopupMenuButton<EncryptionOption>(
onSelected: (result) {
if (result == EncryptionOption.omemo &&
state.conversation!.encrypted == false) {
context.read<ConversationBloc>().add(OmemoSetEvent(true));
} else if (result == EncryptionOption.none &&
state.conversation!.encrypted == true) {
context.read<ConversationBloc>().add(OmemoSetEvent(false));
}
},
icon: (state.conversation?.encrypted ?? false)
? const Icon(Icons.lock)
: const Icon(Icons.lock_open),
itemBuilder: (BuildContext c) => [
popupItemWithIcon<EncryptionOption>(
EncryptionOption.none,
t.pages.conversation.unencrypted,
Icons.lock_open,
),
popupItemWithIcon<EncryptionOption>(
EncryptionOption.omemo,
t.pages.conversation.encrypted,
Icons.lock,
),
],
),
PopupMenuButton<ConversationOption>(
onSelected: (result) async {
switch (result) {
case ConversationOption.close:
{
final result = await showConfirmationDialog(
t.pages.conversation.closeChatConfirmTitle,
t.pages.conversation.closeChatConfirmSubtext,
context,
);
if (result) {
// ignore: use_build_context_synchronously
context.read<ConversationsBloc>().add(
ConversationClosedEvent(
state.conversation!.jid,
),
);
// Navigate back
// ignore: use_build_context_synchronously
context.read<NavigationBloc>().add(
PoppedRouteEvent(),
);
}
}
break;
case ConversationOption.block:
{
await blockJid(state.conversation!.jid, context);
}
break;
}
},
icon: const Icon(Icons.more_vert),
itemBuilder: (BuildContext c) => [
popupItemWithIcon<ConversationOption>(
ConversationOption.close,
t.pages.conversation.closeChat,
Icons.close,
),
if (state.conversation?.type != ConversationType.note)
popupItemWithIcon<ConversationOption>(
ConversationOption.block,
t.pages.conversation.blockUser,
Icons.block,
)
],
),
],
); );
}, },
); );

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
/// A helper widget that displays a [TypingIndicatorWidget] in a stack, so that it can
/// be animated in and out of the bottom of the screen with a "sliding" animation.
class AnimatedTypingIndicator extends StatefulWidget {
const AnimatedTypingIndicator({
required this.visible,
super.key,
});
/// If set to true, animate the typing indicator into view. If false, reverse the
/// animation.
final bool visible;
@override
AnimatedTypingIndicatorState createState() => AnimatedTypingIndicatorState();
}
class AnimatedTypingIndicatorState extends State<AnimatedTypingIndicator>
with TickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_animation = Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic,
),
);
if (widget.visible) {
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
void didUpdateWidget(AnimatedTypingIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) {
if (widget.visible) {
_controller.forward();
} else {
_controller.reverse();
}
}
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
if (_animation.value == 0) {
return const SizedBox();
}
return Stack(
children: [
// Pad the stack to the desired height
SizedBox(
height: 40 * _animation.value,
),
child!,
],
);
},
child: const Positioned(
top: 0,
left: 8,
child: SizedBox(
height: 40,
child: DecoratedBox(
decoration: BoxDecoration(
color: bubbleColorReceived,
borderRadius: BorderRadius.all(radiusLarge),
),
child: Padding(
padding: EdgeInsets.all(8),
child: TypingIndicatorWidget(Colors.black, Colors.white),
),
),
),
),
);
}
}

View File

@@ -12,8 +12,8 @@ import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart'; import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/context_menu.dart';
import 'package:moxxyv2/ui/widgets/conversation.dart'; import 'package:moxxyv2/ui/widgets/conversation.dart';
import 'package:moxxyv2/ui/widgets/overview_menu.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart'; import 'package:moxxyv2/ui/widgets/topbar.dart';
enum ConversationsOptions { settings } enum ConversationsOptions { settings }
@@ -91,38 +91,77 @@ class ConversationsPage extends StatefulWidget {
class ConversationsPageState extends State<ConversationsPage> class ConversationsPageState extends State<ConversationsPage>
with TickerProviderStateMixin { with TickerProviderStateMixin {
late final AnimationController _controller; /// The JID of the currently selected conversation.
late Animation<double> _convY; Conversation? _selectedConversation;
/// Data for the context menu animation
late final AnimationController _contextMenuController;
late final Animation<double> _contextMenuAnimation;
final Map<String, GlobalKey> _conversationKeys = {};
/// The required offset from the top of the stack for the context menu.
double _topStackOffset = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200), _contextMenuController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this, vsync: this,
); );
_contextMenuAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: _contextMenuController,
curve: Curves.easeInOutCubic,
),
);
} }
@override @override
void dispose() { void dispose() {
_controller.dispose(); _contextMenuController.dispose();
super.dispose(); super.dispose();
} }
Widget _listWrapper(BuildContext context, ConversationsState state) { void dismissContextMenu() {
final maxTextWidth = MediaQuery.of(context).size.width * 0.6; _contextMenuController.reverse();
setState(() {
_selectedConversation = null;
});
}
Widget _listWrapper(BuildContext context, ConversationsState state) {
if (state.conversations.isNotEmpty) { if (state.conversations.isNotEmpty) {
return ListView.builder( return ListView.builder(
itemCount: state.conversations.length, itemCount: state.conversations.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = state.conversations[index]; final item = state.conversations[index];
GlobalKey key;
if (_conversationKeys.containsKey(item.jid)) {
key = _conversationKeys[item.jid]!;
} else {
key = GlobalKey();
_conversationKeys[item.jid] = key;
}
final row = ConversationsListRow( final row = ConversationsListRow(
maxTextWidth,
item, item,
true, true,
enableAvatarOnTap: true, enableAvatarOnTap: true,
key: ValueKey('conversationRow;${item.jid}'), isSelected: _selectedConversation?.jid == item.jid,
onPressed: () => GetIt.I.get<ConversationBloc>().add(
RequestedConversationEvent(
item.jid,
item.title,
item.avatarUrl,
),
),
key: key,
); );
return ConversationsRowDismissible( return ConversationsRowDismissible(
@@ -131,79 +170,40 @@ class ConversationsPageState extends State<ConversationsPage>
onLongPressStart: (event) async { onLongPressStart: (event) async {
Vibrate.feedback(FeedbackType.medium); Vibrate.feedback(FeedbackType.medium);
_convY = Tween<double>( final widgetRect = getWidgetPositionOnScreen(key);
begin: event.globalPosition.dy - 20, final height = MediaQuery.of(context).size.height;
end: 200,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic,
),
);
await _controller.forward(); setState(() {
_selectedConversation = item;
// ignore: use_build_context_synchronously final numberOptions = item.numberContextMenuOptions;
await showDialog<void>( if (height - widgetRect.bottom >
context: context, 40 + numberOptions * ContextMenuItem.height) {
builder: (context) => OverviewMenu( // In this case, we have enough space below the conversation item,
_convY, // so we say that the top of the context menu is
highlight: row, // widgetRect.bottom (Bottom y coordinate of the conversation item)
left: 0, // minus 20 (padding so we're not directly against the conversation
right: 0, // item) - the height of the top bar.
children: [ _topStackOffset = widgetRect.bottom -
if (item.unreadCounter != 0) 20 -
OverviewMenuItem( BorderlessTopbar.topbarPreferredHeight;
icon: Icons.done_all, } else {
text: t.pages.conversations.markAsRead, // In this case we don't have sufficient space below the conversation
onPressed: () { // item, so we place the context menu above it.
context.read<ConversationsBloc>().add( // The computation is the same as in the above branch, but now
ConversationMarkedAsReadEvent(item.jid), // we position the context menu above and thus also substract the
); // height of the context menu
Navigator.of(context).pop(); // (numberOptions * ContextMenuItem.height).
}, _topStackOffset = widgetRect.top -
), 20 -
OverviewMenuItem( numberOptions * ContextMenuItem.height -
icon: Icons.close, BorderlessTopbar.topbarPreferredHeight;
text: t.pages.conversations.closeChat, }
onPressed: () async { });
// ignore: use_build_context_synchronously
final result = await showConfirmationDialog(
t.pages.conversations.closeChat,
t.pages.conversations.closeChatBody(
conversationTitle: item.title,
),
context,
);
if (result) { await _contextMenuController.forward();
// TODO(Unknown): Show a snackbar allowing the user to revert the action
// ignore: use_build_context_synchronously
context.read<ConversationsBloc>().add(
ConversationClosedEvent(item.jid),
);
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
}
},
),
],
),
);
await _controller.reverse();
}, },
child: InkWell( child: row,
onTap: () => GetIt.I.get<ConversationBloc>().add(
RequestedConversationEvent(
item.jid,
item.title,
item.avatarUrl,
),
),
child: row,
),
), ),
); );
}, },
@@ -236,31 +236,59 @@ class ConversationsPageState extends State<ConversationsPage>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<ConversationsBloc, ConversationsState>( return BlocBuilder<ConversationsBloc, ConversationsState>(
builder: (BuildContext context, ConversationsState state) => Scaffold( builder: (BuildContext context, ConversationsState state) => Scaffold(
appBar: BorderlessTopbar.avatarAndName( appBar: BorderlessTopbar(
TopbarAvatarAndName( showBackButton: false,
TopbarTitleText(state.displayName), children: [
Hero( Expanded(
tag: 'self_profile_picture', child: InkWell(
child: Material( onTap: () {
color: const Color.fromRGBO(0, 0, 0, 0), // Dismiss the selection, if we have an active one
child: AvatarWrapper( if (_selectedConversation != null) {
radius: 20, dismissContextMenu();
avatarUrl: state.avatarUrl, }
altIcon: Icons.person,
GetIt.I.get<profile.ProfileBloc>().add(
profile.ProfilePageRequestedEvent(
true,
jid: state.jid,
avatarUrl: state.avatarUrl,
displayName: state.displayName,
),
);
},
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Hero(
tag: 'self_profile_picture',
child: Material(
color: const Color.fromRGBO(0, 0, 0, 0),
child: AvatarWrapper(
radius: 20,
avatarUrl: state.avatarUrl,
altIcon: Icons.person,
),
),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
state.displayName,
style: const TextStyle(
fontSize: fontsizeAppbar,
),
),
),
],
),
), ),
), ),
), ),
() => GetIt.I.get<profile.ProfileBloc>().add( Padding(
profile.ProfilePageRequestedEvent( padding: const EdgeInsets.all(8),
true, child: PopupMenuButton(
jid: state.jid,
avatarUrl: state.avatarUrl,
displayName: state.displayName,
),
),
showBackButton: false,
extra: [
PopupMenuButton(
onSelected: (ConversationsOptions result) { onSelected: (ConversationsOptions result) {
switch (result) { switch (result) {
case ConversationsOptions.settings: case ConversationsOptions.settings:
@@ -275,11 +303,86 @@ class ConversationsPageState extends State<ConversationsPage>
child: Text(t.pages.conversations.overlaySettings), child: Text(t.pages.conversations.overlaySettings),
) )
], ],
) ),
], ),
), ],
),
body: Stack(
children: [
_listWrapper(context, state),
if (_selectedConversation != null)
Positioned(
top: 0,
left: 0,
right: 0,
bottom: 0,
child: GestureDetector(
onTap: dismissContextMenu,
// NOTE: We must set the color to Colors.transparent because the container
// would otherwise not span the entire screen (or Scaffold body to be
// more precise).
child: const ColoredBox(
color: Colors.transparent,
),
),
),
Positioned(
top: _topStackOffset,
left: 8,
child: AnimatedBuilder(
animation: _contextMenuAnimation,
builder: (context, child) => IgnorePointer(
ignoring: _selectedConversation == null,
child: Opacity(
opacity: _contextMenuAnimation.value,
child: child,
),
),
child: ContextMenu(
children: [
if ((_selectedConversation?.unreadCounter ?? 0) > 0)
ContextMenuItem(
icon: Icons.done_all,
text: t.pages.conversations.markAsRead,
onPressed: () {
context.read<ConversationsBloc>().add(
ConversationMarkedAsReadEvent(
_selectedConversation!.jid,
),
);
},
),
ContextMenuItem(
icon: Icons.close,
text: t.pages.conversations.closeChat,
onPressed: () async {
// ignore: use_build_context_synchronously
final result = await showConfirmationDialog(
t.pages.conversations.closeChat,
t.pages.conversations.closeChatBody(
conversationTitle:
_selectedConversation?.title ?? '',
),
context,
);
if (result) {
// TODO(Unknown): Show a snackbar allowing the user to revert the action
// ignore: use_build_context_synchronously
context.read<ConversationsBloc>().add(
ConversationClosedEvent(
_selectedConversation!.jid,
),
);
}
},
),
],
),
),
),
],
), ),
body: _listWrapper(context, state),
floatingActionButton: SpeedDial( floatingActionButton: SpeedDial(
icon: Icons.chat, icon: Icons.chat,
curve: Curves.bounceInOut, curve: Curves.bounceInOut,

View File

@@ -23,7 +23,7 @@ class Login extends StatelessWidget {
builder: (BuildContext context, LoginState state) => WillPopScope( builder: (BuildContext context, LoginState state) => WillPopScope(
onWillPop: () async => !state.working, onWillPop: () async => !state.working,
child: Scaffold( child: Scaffold(
appBar: BorderlessTopbar.simple(t.pages.login.title), appBar: BorderlessTopbar.title(t.pages.login.title),
body: Column( body: Column(
children: [ children: [
Visibility( Visibility(

View File

@@ -20,38 +20,45 @@ class NewConversationPage extends StatelessWidget {
), ),
); );
Widget _renderIconEntry(IconData icon, String text, void Function() onTap) { Widget _renderIconEntry(IconData icon, String text, VoidCallback onTap) {
return InkWell( return Padding(
onTap: onTap, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row( child: ClipRRect(
children: [ borderRadius: BorderRadius.circular(radiusLargeSize),
Padding( child: Material(
padding: const EdgeInsets.all(8), child: InkWell(
child: AvatarWrapper( onTap: onTap,
radius: 35, child: Row(
altIcon: icon, children: [
Padding(
padding: const EdgeInsets.all(8),
child: AvatarWrapper(
radius: 35,
altIcon: icon,
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
text,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.bold,
),
),
)
],
), ),
), ),
Padding( ),
padding: const EdgeInsets.all(8),
child: Text(
text,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.bold,
),
),
)
],
), ),
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.newconversation.title), appBar: BorderlessTopbar.title(t.pages.newconversation.title),
body: BlocBuilder<NewConversationBloc, NewConversationState>( body: BlocBuilder<NewConversationBloc, NewConversationState>(
builder: (BuildContext context, NewConversationState state) => builder: (BuildContext context, NewConversationState state) =>
ListView.builder( ListView.builder(
@@ -87,8 +94,39 @@ class NewConversationPage extends StatelessWidget {
), ),
), ),
), ),
child: InkWell( child: ConversationsListRow(
onTap: () => context.read<NewConversationBloc>().add( Conversation(
item.title,
Message(
'',
item.jid,
0,
'',
0,
'',
false,
false,
false,
),
item.avatarUrl,
item.jid,
0,
ConversationType.chat,
0,
true,
true,
'',
false,
false,
ChatState.gone,
contactId: item.contactId,
contactAvatarPath: item.contactAvatarPath,
contactDisplayName: item.contactDisplayName,
),
false,
showTimestamp: false,
isSelected: false,
onPressed: () => context.read<NewConversationBloc>().add(
NewConversationAddedEvent( NewConversationAddedEvent(
item.jid, item.jid,
item.title, item.title,
@@ -96,44 +134,8 @@ class NewConversationPage extends StatelessWidget {
ConversationType.chat, ConversationType.chat,
), ),
), ),
child: ConversationsListRow( titleSuffixIcon:
maxTextWidth, item.pseudoRosterItem ? Icons.smartphone : null,
Conversation(
item.title,
Message(
'',
item.jid,
0,
'',
0,
'',
false,
false,
false,
false,
),
item.avatarUrl,
item.jid,
0,
ConversationType.chat,
0,
[],
true,
true,
'',
false,
false,
ChatState.gone,
0,
contactId: item.contactId,
contactAvatarPath: item.contactAvatarPath,
contactDisplayName: item.contactDisplayName,
),
false,
showTimestamp: false,
titleSuffixIcon:
item.pseudoRosterItem ? Icons.smartphone : null,
),
), ),
); );
} }

View File

@@ -11,8 +11,7 @@ import 'package:moxxyv2/ui/widgets/profile/options.dart';
//import 'package:phosphor_flutter/phosphor_flutter.dart'; //import 'package:phosphor_flutter/phosphor_flutter.dart';
class ConversationProfileHeader extends StatelessWidget { class ConversationProfileHeader extends StatelessWidget {
const ConversationProfileHeader(this.conversation, {super.key}); const ConversationProfileHeader({super.key});
final Conversation conversation;
Future<void> _showAvatarFullsize(BuildContext context, String path) async { Future<void> _showAvatarFullsize(BuildContext context, String path) async {
await showDialog<void>( await showDialog<void>(
@@ -25,7 +24,7 @@ class ConversationProfileHeader extends StatelessWidget {
); );
} }
Widget _buildAvatar(BuildContext context) { Widget _buildAvatar(BuildContext context, Conversation conversation) {
return RebuildOnContactIntegrationChange( return RebuildOnContactIntegrationChange(
builder: () { builder: () {
final path = conversation.avatarPathWithOptionalContact; final path = conversation.avatarPathWithOptionalContact;
@@ -49,77 +48,83 @@ class ConversationProfileHeader extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return BlocBuilder<ProfileBloc, ProfileState>(
children: [ builder: (context, state) {
Hero( final conversation = state.conversation!;
tag: 'conversation_profile_picture', return Column(
child: Material( children: [
child: _buildAvatar( Hero(
context, tag: 'conversation_profile_picture',
), child: Material(
), child: _buildAvatar(
), context,
Padding( conversation,
padding: const EdgeInsets.only(top: 8), ),
child: RebuildOnContactIntegrationChange(
builder: () => Text(
conversation.titleWithOptionalContact,
style: const TextStyle(
fontSize: 30,
), ),
), ),
), Padding(
), padding: const EdgeInsets.only(top: 8),
Padding( child: RebuildOnContactIntegrationChange(
padding: const EdgeInsets.only(top: 3), builder: () => Text(
child: Text( conversation.titleWithOptionalContact,
conversation.jid, style: const TextStyle(
style: const TextStyle( fontSize: 30,
fontSize: 15, ),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
top: 16,
left: 64,
right: 64,
),
child: ProfileOptions(
options: [
ProfileOption(
icon: Icons.security_outlined,
title: t.pages.profile.general.omemo,
onTap: () {
context.read<DevicesBloc>().add(
DevicesRequestedEvent(conversation.jid),
);
},
), ),
ProfileOption( ),
icon: conversation.muted
? Icons.notifications_off
: Icons.notifications,
title: t.pages.profile.conversation.notifications,
description: conversation.muted
? t.pages.profile.conversation.notificationsMuted
: t.pages.profile.conversation.notificationsEnabled,
onTap: () {
context.read<ProfileBloc>().add(
MuteStateSetEvent(
conversation.jid,
!conversation.muted,
),
);
},
),
],
), ),
), Padding(
), padding: const EdgeInsets.only(top: 3),
], child: Text(
conversation.jid,
style: const TextStyle(
fontSize: 15,
),
),
),
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
top: 16,
left: 64,
right: 64,
),
child: ProfileOptions(
options: [
ProfileOption(
icon: Icons.security_outlined,
title: t.pages.profile.general.omemo,
onTap: () {
context.read<DevicesBloc>().add(
DevicesRequestedEvent(conversation.jid),
);
},
),
ProfileOption(
icon: conversation.muted
? Icons.notifications_off
: Icons.notifications,
title: t.pages.profile.conversation.notifications,
description: conversation.muted
? t.pages.profile.conversation.notificationsMuted
: t.pages.profile.conversation.notificationsEnabled,
onTap: () {
context.read<ProfileBloc>().add(
MuteStateSetEvent(
conversation.jid,
!conversation.muted,
),
);
},
),
],
),
),
),
],
);
},
); );
} }
} }

View File

@@ -100,26 +100,23 @@ class DevicesPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<DevicesBloc, DevicesState>( return BlocBuilder<DevicesBloc, DevicesState>(
builder: (context, state) => Scaffold( builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple( appBar: BorderlessTopbar.title(
t.pages.profile.devices.title, t.pages.profile.devices.title,
extra: [ trailing: PopupMenuButton(
const Spacer(), onSelected: (DevicesOptions result) {
PopupMenuButton( if (result == DevicesOptions.recreateSessions) {
onSelected: (DevicesOptions result) { _recreateSessions(context);
if (result == DevicesOptions.recreateSessions) { }
_recreateSessions(context); },
} icon: const Icon(Icons.more_vert),
}, itemBuilder: (BuildContext context) => [
icon: const Icon(Icons.more_vert), PopupMenuItem(
itemBuilder: (BuildContext context) => [ value: DevicesOptions.recreateSessions,
PopupMenuItem( enabled: state.devices.isNotEmpty,
value: DevicesOptions.recreateSessions, child: Text(t.pages.profile.devices.recreateSessions),
enabled: state.devices.isNotEmpty, )
child: Text(t.pages.profile.devices.recreateSessions), ],
) ),
],
),
],
), ),
body: _buildBody(context, state), body: _buildBody(context, state),
), ),

View File

@@ -180,35 +180,32 @@ class OwnDevicesPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<OwnDevicesBloc, OwnDevicesState>( return BlocBuilder<OwnDevicesBloc, OwnDevicesState>(
builder: (context, state) => Scaffold( builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple( appBar: BorderlessTopbar.title(
t.pages.profile.owndevices.title, t.pages.profile.owndevices.title,
extra: [ trailing: PopupMenuButton(
const Spacer(), onSelected: (OwnDevicesOptions result) {
PopupMenuButton( switch (result) {
onSelected: (OwnDevicesOptions result) { case OwnDevicesOptions.recreateSessions:
switch (result) { _recreateSessions(context);
case OwnDevicesOptions.recreateSessions: break;
_recreateSessions(context); case OwnDevicesOptions.recreateDevice:
break; _recreateDevice(context);
case OwnDevicesOptions.recreateDevice: break;
_recreateDevice(context); }
break; },
} icon: const Icon(Icons.more_vert),
}, itemBuilder: (BuildContext context) => [
icon: const Icon(Icons.more_vert), PopupMenuItem(
itemBuilder: (BuildContext context) => [ value: OwnDevicesOptions.recreateSessions,
PopupMenuItem( enabled: state.keys.isNotEmpty,
value: OwnDevicesOptions.recreateSessions, child: Text(t.pages.profile.owndevices.recreateOwnSessions),
enabled: state.keys.isNotEmpty, ),
child: Text(t.pages.profile.owndevices.recreateOwnSessions), PopupMenuItem(
), value: OwnDevicesOptions.recreateDevice,
PopupMenuItem( child: Text(t.pages.profile.owndevices.recreateOwnDevice),
value: OwnDevicesOptions.recreateDevice, ),
child: Text(t.pages.profile.owndevices.recreateOwnDevice), ],
), ),
],
),
],
), ),
body: _buildBody(context, state), body: _buildBody(context, state),
), ),

View File

@@ -1,90 +1,137 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart'; import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/pages/profile/conversationheader.dart'; import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
import 'package:moxxyv2/ui/pages/profile/selfheader.dart'; import 'package:moxxyv2/ui/pages/profile/profile_view.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart'; import 'package:moxxyv2/ui/pages/profile/shared_media_view.dart';
class ProfilePage extends StatelessWidget { class ProfileArguments {
const ProfilePage({super.key}); ProfileArguments(this.isSelfProfile, this.jid);
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>( bool isSelfProfile;
builder: (_) => const ProfilePage(),
/// The JID of the conversation entity.
String jid;
}
class ProfilePage extends StatefulWidget {
const ProfilePage(this.arguments, {super.key});
/// The arguments passed to the page
final ProfileArguments arguments;
static MaterialPageRoute<dynamic> getRoute(ProfileArguments arguments) =>
MaterialPageRoute<dynamic>(
builder: (_) => ProfilePage(arguments),
settings: const RouteSettings( settings: const RouteSettings(
name: profileRoute, name: profileRoute,
), ),
); );
Widget _buildHeader(BuildContext context, ProfileState state) { @override
if (state.isSelfProfile) { ProfilePageState createState() => ProfilePageState();
return SelfProfileHeader( }
state.jid,
state.avatarUrl,
state.displayName,
(path, hash) => context.read<ProfileBloc>().add(
AvatarSetEvent(path, hash),
),
);
}
return ConversationProfileHeader(state.conversation!); class ProfilePageState extends State<ProfilePage> {
late final PageController _pageController;
int _pageIndex = 0;
late final BidirectionalSharedMediaController _mediaController;
@override
void initState() {
super.initState();
_pageController = PageController()..addListener(_onPageControllerUpdate);
_mediaController = BidirectionalSharedMediaController(
widget.arguments.jid,
);
}
@override
void dispose() {
_pageController.dispose();
_mediaController.dispose();
super.dispose();
}
void _onPageControllerUpdate() {
if (_pageController.hasClients) {
final page = _pageController.page!.round();
if (page != _pageIndex) {
setState(() {
_pageIndex = page;
});
} else if (_pageController.page! >= 0.5 &&
!_mediaController.hasFetchedOnce) {
_mediaController.fetchOlderData();
}
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return SafeArea(
child: Scaffold( child: Stack(
body: BlocBuilder<ProfileBloc, ProfileState>( children: [
builder: (context, state) => Stack( Scaffold(
alignment: Alignment.center, bottomNavigationBar: !widget.arguments.isSelfProfile
children: [ ? BottomNavigationBar(
ListView( currentIndex: _pageIndex,
children: [ onTap: (index) {
Padding( _pageController.animateToPage(
padding: const EdgeInsets.only(top: 8), index,
child: _buildHeader(context, state), duration: const Duration(milliseconds: 300),
), curve: Curves.easeInOutQuint,
if (!state.isSelfProfile && );
state.conversation!.sharedMedia.isNotEmpty) setState(() {
SharedMediaDisplay( _pageIndex = index;
preview: state.conversation!.sharedMedia, });
jid: state.conversation!.jid,
title: state.conversation!.titleWithOptionalContact,
sharedMediaAmount: state.conversation!.sharedMediaAmount,
),
],
),
Positioned(
top: 8,
left: 8,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () =>
context.read<NavigationBloc>().add(PoppedRouteEvent()),
),
),
Positioned(
top: 8,
right: 8,
child: Visibility(
visible: state.isSelfProfile,
child: IconButton(
color: Colors.white,
icon: const Icon(Icons.info_outline),
onPressed: () {
context
.read<ServerInfoBloc>()
.add(ServerInfoPageRequested());
}, },
), items: [
BottomNavigationBarItem(
icon: const Icon(Icons.person),
label: t.pages.profile.general.profile,
),
BottomNavigationBarItem(
icon: const Icon(Icons.perm_media),
label: t.pages.profile.general.media,
),
],
)
: null,
body: PageView(
controller: _pageController,
physics: widget.arguments.isSelfProfile
? const NeverScrollableScrollPhysics()
: null,
children: [
ProfileView(
widget.arguments,
), ),
), SharedMediaView(
], _mediaController,
key: const PageStorageKey('shared_media_view'),
),
],
),
), ),
), Positioned(
top: 8,
left: 8,
child: Material(
color: Colors.transparent,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: () =>
context.read<NavigationBloc>().add(PoppedRouteEvent()),
),
),
),
],
), ),
); );
} }

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
import 'package:moxxyv2/ui/pages/profile/conversationheader.dart';
import 'package:moxxyv2/ui/pages/profile/profile.dart';
import 'package:moxxyv2/ui/pages/profile/selfheader.dart';
class ProfileView extends StatelessWidget {
const ProfileView(this.arguments, {super.key});
final ProfileArguments arguments;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
alignment: Alignment.center,
children: [
ListView(
children: [
Padding(
padding: const EdgeInsets.only(top: 8),
child: arguments.isSelfProfile
? SelfProfileHeader(arguments)
: const ConversationProfileHeader(),
),
],
),
Positioned(
top: 8,
right: 8,
child: Visibility(
visible: arguments.isSelfProfile,
child: IconButton(
color: Colors.white,
icon: const Icon(Icons.info_outline),
onPressed: () {
context.read<ServerInfoBloc>().add(ServerInfoPageRequested());
},
),
),
),
],
),
);
}
}

View File

@@ -2,105 +2,108 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.dart'; import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart'; import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart';
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/pages/profile/profile.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart'; import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/profile/options.dart'; import 'package:moxxyv2/ui/widgets/profile/options.dart';
class SelfProfileHeader extends StatelessWidget { class SelfProfileHeader extends StatelessWidget {
const SelfProfileHeader( const SelfProfileHeader(this.arguments, {super.key});
this.jid,
this.avatarUrl,
this.displayName,
this.setAvatar, {
super.key,
});
final String jid;
final String avatarUrl;
final String displayName;
final void Function(String, String) setAvatar;
Future<void> pickAndSetAvatar(BuildContext context) async { final ProfileArguments arguments;
final avatar = await pickAvatar(context, jid, avatarUrl);
Future<void> pickAndSetAvatar(BuildContext context, String avatarUrl) async {
final avatar = await pickAvatar(context, arguments.jid, avatarUrl);
if (avatar != null) { if (avatar != null) {
setAvatar(avatar.path, avatar.hash); // ignore: use_build_context_synchronously
context.read<ProfileBloc>().add(
AvatarSetEvent(avatar.path, avatar.hash),
);
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return BlocBuilder<ProfileBloc, ProfileState>(
children: [ builder: (context, state) {
Hero( return Column(
tag: 'self_profile_picture', children: [
child: Material( Hero(
child: AvatarWrapper( tag: 'self_profile_picture',
radius: 110, child: Material(
avatarUrl: avatarUrl, child: AvatarWrapper(
altIcon: Icons.person, radius: 110,
onTapFunction: () => pickAndSetAvatar(context), avatarUrl: state.avatarUrl,
), altIcon: Icons.person,
), onTapFunction: () =>
), pickAndSetAvatar(context, state.avatarUrl),
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
displayName,
style: const TextStyle(
fontSize: 20,
),
)
],
),
),
Padding(
padding: const EdgeInsets.only(top: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
jid,
style: const TextStyle(
fontSize: 15,
), ),
), ),
Padding(
padding: const EdgeInsetsDirectional.only(start: 3),
child: IconButton(
icon: const Icon(Icons.qr_code),
onPressed: () => showQrCode(context, 'xmpp:$jid'),
),
)
],
),
),
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
top: 16,
left: 64,
right: 64,
), ),
child: ProfileOptions( Padding(
options: [ padding: const EdgeInsets.only(top: 8),
ProfileOption( child: Row(
icon: Icons.security_outlined, mainAxisAlignment: MainAxisAlignment.center,
title: t.pages.profile.general.omemo, children: [
onTap: () { Text(
context.read<OwnDevicesBloc>().add( state.displayName,
OwnDevicesRequestedEvent(), style: const TextStyle(
); fontSize: 20,
}, ),
), )
], ],
),
), ),
), Padding(
), padding: const EdgeInsets.only(top: 3),
], child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
arguments.jid,
style: const TextStyle(
fontSize: 15,
),
),
Padding(
padding: const EdgeInsetsDirectional.only(start: 3),
child: IconButton(
icon: const Icon(Icons.qr_code),
onPressed: () =>
showQrCode(context, 'xmpp:${arguments.jid}'),
),
)
],
),
),
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(
top: 16,
left: 64,
right: 64,
),
child: ProfileOptions(
options: [
ProfileOption(
icon: Icons.security_outlined,
title: t.pages.profile.general.omemo,
onTap: () {
context.read<OwnDevicesBloc>().add(
OwnDevicesRequestedEvent(),
);
},
),
],
),
),
),
],
);
},
); );
} }
} }

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
import 'package:moxxyv2/ui/widgets/chat/message.dart';
import 'package:moxxyv2/ui/widgets/grouped_grid_view.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
class SharedMediaView extends StatelessWidget {
const SharedMediaView(this.mediaController, {super.key});
/// The controller used for requesting shared media messages.
final BidirectionalSharedMediaController mediaController;
@override
Widget build(BuildContext context) {
final now = DateTime.now();
return Scaffold(
appBar: const BorderlessTopbar(
showBackButton: false,
// Ensure the top bar has a height
children: [
SizedBox(
height: BorderlessTopbar.topbarPreferredHeight,
),
],
),
body: Stack(
children: [
Positioned(
top: 0,
left: 0,
right: 0,
child: StreamBuilder<bool>(
stream: mediaController.isFetchingStream,
initialData: mediaController.isFetching,
builder: (context, snapshot) {
return snapshot.data!
? const LinearProgressIndicator()
: const SizedBox();
},
),
),
Positioned(
top: 0,
bottom: 0,
left: 0,
right: 0,
child: StreamBuilder<List<Message>>(
stream: mediaController.dataStream,
initialData: mediaController.cache,
builder: (context, snapshot) {
return GroupedGridView<Message, DateTime>(
controller: mediaController.scrollController,
elements: snapshot.data!,
getKey: (m) {
final dt = DateTime.fromMillisecondsSinceEpoch(m.timestamp);
return DateTime(
dt.year,
dt.month,
dt.day,
);
},
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
gridPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemBuilder: (_, message) => buildSharedMediaWidget(
message.fileMetadata!,
message.conversationJid,
),
separatorBuilder: (_, timestamp) => Padding(
padding: const EdgeInsets.only(
top: 4,
bottom: 4,
left: 16,
),
child: Text(
formatDateBubble(timestamp, now),
style: const TextStyle(
fontSize: 25,
),
),
),
);
},
),
),
],
),
);
}
}

View File

@@ -21,7 +21,8 @@ class ServerInfoPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple('Server Information'), // TODO(PapaTutuWawa): Translate
appBar: BorderlessTopbar.title('Server Information'),
body: BlocBuilder<ServerInfoBloc, ServerInfoState>( body: BlocBuilder<ServerInfoBloc, ServerInfoState>(
builder: (BuildContext context, ServerInfoState state) { builder: (BuildContext context, ServerInfoState state) {
if (state.working) { if (state.working) {

View File

@@ -43,7 +43,7 @@ class SettingsAboutPageState extends State<SettingsAboutPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.about.title), appBar: BorderlessTopbar.title(t.pages.settings.about.title),
body: Padding( body: Padding(
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge), padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
child: Column( child: Column(

View File

@@ -50,7 +50,7 @@ class AppearanceSettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.appearance.title), appBar: BorderlessTopbar.title(t.pages.settings.appearance.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>( body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => ListView( builder: (context, state) => ListView(
children: [ children: [

View File

@@ -69,7 +69,7 @@ class ConversationSettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.conversation.title), appBar: BorderlessTopbar.title(t.pages.settings.conversation.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>( body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => ListView( builder: (context, state) => ListView(
children: [ children: [

View File

@@ -28,7 +28,7 @@ class DebuggingPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.debugging.title), appBar: BorderlessTopbar.title(t.pages.settings.debugging.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>( body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => ListView( builder: (context, state) => ListView(
children: [ children: [

View File

@@ -52,7 +52,7 @@ class SettingsLicensesPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.licenses.title), appBar: BorderlessTopbar.title(t.pages.settings.licenses.title),
body: ListView.builder( body: ListView.builder(
itemCount: usedLibraryList.length, itemCount: usedLibraryList.length,
itemBuilder: (context, index) => itemBuilder: (context, index) =>

View File

@@ -106,7 +106,7 @@ class NetworkPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.network.title), appBar: BorderlessTopbar.title(t.pages.settings.network.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>( body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => ListView( builder: (context, state) => ListView(
children: [ children: [

View File

@@ -22,7 +22,7 @@ class PrivacyPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.privacy.title), appBar: BorderlessTopbar.title(t.pages.settings.privacy.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>( body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (context, state) => ListView( builder: (context, state) => ListView(
children: [ children: [

View File

@@ -26,7 +26,7 @@ class SettingsPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.settings.settings.title), appBar: BorderlessTopbar.title(t.pages.settings.settings.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>( body: BlocBuilder<PreferencesBloc, PreferencesState>(
buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu, buildWhen: (prev, next) => prev.showDebugMenu != next.showDebugMenu,
builder: (context, state) => ListView( builder: (context, state) => ListView(

View File

@@ -37,8 +37,7 @@ class StickersSettingsPage extends StatelessWidget {
right: 0, right: 0,
bottom: 0, bottom: 0,
child: Scaffold( child: Scaffold(
appBar: appBar: BorderlessTopbar.title(t.pages.settings.stickers.title),
BorderlessTopbar.simple(t.pages.settings.stickers.title),
body: BlocBuilder<PreferencesBloc, PreferencesState>( body: BlocBuilder<PreferencesBloc, PreferencesState>(
builder: (_, prefs) => Padding( builder: (_, prefs) => Padding(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,

View File

@@ -46,8 +46,6 @@ class ShareSelectionPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
return WillPopScope( return WillPopScope(
onWillPop: () async { onWillPop: () async {
GetIt.I.get<ShareSelectionBloc>().add(ResetEvent()); GetIt.I.get<ShareSelectionBloc>().add(ResetEvent());
@@ -67,54 +65,40 @@ class ShareSelectionPage extends StatelessWidget {
child: BlocBuilder<ShareSelectionBloc, ShareSelectionState>( child: BlocBuilder<ShareSelectionBloc, ShareSelectionState>(
buildWhen: _buildWhen, buildWhen: _buildWhen,
builder: (context, state) => Scaffold( builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(t.pages.shareselection.shareWith), appBar: BorderlessTopbar.title(t.pages.shareselection.shareWith),
body: ListView.builder( body: ListView.builder(
itemCount: state.items.length, itemCount: state.items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = state.items[index]; final item = state.items[index];
final isSelected = state.selection.contains(index);
return InkWell( return ConversationsListRow(
onTap: () { Conversation(
item.title,
null,
item.avatarPath,
item.jid,
0,
ConversationType.chat,
0,
true,
true,
'',
false,
false,
ChatState.gone,
contactId: item.contactId,
contactAvatarPath: item.contactAvatarPath,
contactDisplayName: item.contactDisplayName,
),
false,
titleSuffixIcon: _getSuffixIcon(item),
showTimestamp: false,
isSelected: state.selection.contains(index),
onPressed: () {
context.read<ShareSelectionBloc>().add( context.read<ShareSelectionBloc>().add(
SelectionToggledEvent(index), SelectionToggledEvent(index),
); );
}, },
child: ConversationsListRow(
maxTextWidth,
Conversation(
item.title,
null,
item.avatarPath,
item.jid,
0,
ConversationType.chat,
0,
[],
true,
true,
'',
false,
false,
ChatState.gone,
0,
contactId: item.contactId,
contactAvatarPath: item.contactAvatarPath,
contactDisplayName: item.contactDisplayName,
),
false,
titleSuffixIcon: _getSuffixIcon(item),
showTimestamp: false,
extraWidgetWidth: 48,
extra: Checkbox(
value: isSelected,
onChanged: (_) {
context.read<ShareSelectionBloc>().add(
SelectionToggledEvent(index),
);
},
),
),
); );
}, },
), ),

View File

@@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
import 'package:moxxyv2/ui/widgets/chat/message.dart';
import 'package:moxxyv2/ui/widgets/grouped_grid_view.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
class SharedMediaPageArguments {
SharedMediaPageArguments(
this.conversationJid,
this.conversationTitle,
);
/// The JID of the conversation we display the shared media items of.
final String conversationJid;
/// The title of the conversation.
final String conversationTitle;
}
class SharedMediaPage extends StatefulWidget {
const SharedMediaPage({
required this.arguments,
super.key,
});
static MaterialPageRoute<void> getRoute(SharedMediaPageArguments arguments) {
return MaterialPageRoute<void>(
builder: (_) => SharedMediaPage(arguments: arguments),
settings: const RouteSettings(
name: sharedMediaRoute,
),
);
}
/// The arguments passed to the page.
final SharedMediaPageArguments arguments;
@override
SharedMediaPageState createState() => SharedMediaPageState();
}
class SharedMediaPageState extends State<SharedMediaPage> {
late final BidirectionalSharedMediaController _controller;
@override
void initState() {
super.initState();
_controller =
BidirectionalSharedMediaController(widget.arguments.conversationJid);
_controller.fetchOlderData();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final now = DateTime.now();
return Scaffold(
appBar: BorderlessTopbar.simple(widget.arguments.conversationTitle),
body: StreamBuilder<List<SharedMedium>>(
initialData: const [],
stream: _controller.dataStream,
builder: (context, snapshot) {
return GroupedGridView<SharedMedium, DateTime>(
controller: _controller.scrollController,
elements: snapshot.data!,
getKey: (m) {
final dt = DateTime.fromMillisecondsSinceEpoch(m.timestamp);
return DateTime(
dt.year,
dt.month,
dt.day,
);
},
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
gridPadding: const EdgeInsets.symmetric(
horizontal: 16,
),
itemBuilder: (_, medium) => buildSharedMediaWidget(
medium,
widget.arguments.conversationJid,
),
separatorBuilder: (_, timestamp) => Padding(
padding: const EdgeInsets.only(
top: 4,
bottom: 4,
left: 16,
),
child: Text(
formatDateBubble(timestamp, now),
style: const TextStyle(
fontSize: 25,
),
),
),
);
},
),
);
}
}

View File

@@ -22,14 +22,14 @@ class StickerWrapper extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (sticker.path.isNotEmpty) { if (sticker.fileMetadata.path != null) {
return Image.file( return Image.file(
File(sticker.path), File(sticker.fileMetadata.path!),
fit: cover ? BoxFit.contain : null, fit: cover ? BoxFit.contain : null,
); );
} else { } else {
return Image.network( return Image.network(
sticker.urlSources.first, sticker.fileMetadata.sourceUrls!.first,
fit: cover ? BoxFit.contain : null, fit: cover ? BoxFit.contain : null,
loadingBuilder: (_, child, event) { loadingBuilder: (_, child, event) {
if (event == null) return child; if (event == null) return child;
@@ -214,9 +214,7 @@ class StickerPackPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<StickerPackBloc, StickerPackState>( return BlocBuilder<StickerPackBloc, StickerPackState>(
builder: (context, state) => Scaffold( builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple( appBar: BorderlessTopbar.title(state.stickerPack?.name ?? '...'),
state.stickerPack?.name ?? '...',
),
body: state.isWorking body: state.isWorking
? SizedBox( ? SizedBox(
width: MediaQuery.of(context).size.width, width: MediaQuery.of(context).size.width,

View File

@@ -100,8 +100,8 @@ class MessageBubbleBottomState extends State<MessageBubbleBottom> {
Padding( Padding(
padding: const EdgeInsets.only(top: 3), padding: const EdgeInsets.only(top: 3),
child: Text( child: Text(
widget.message.isMedia && widget.message.mediaSize != null widget.message.isMedia && widget.message.fileMetadata!.size != null
? '${fileSizeToString(widget.message.mediaSize!)}$_timestampString' ? '${fileSizeToString(widget.message.fileMetadata!.size!)}$_timestampString'
: _timestampString, : _timestampString,
style: const TextStyle( style: const TextStyle(
fontSize: fontsizeSubbody, fontSize: fontsizeSubbody,

View File

@@ -3,10 +3,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_vibrate/flutter_vibrate.dart'; import 'package:flutter_vibrate/flutter_vibrate.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/reaction.dart';
import 'package:moxxyv2/ui/constants.dart'; import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/widgets/chat/message.dart'; import 'package:moxxyv2/ui/widgets/chat/message.dart';
import 'package:moxxyv2/ui/widgets/chat/reactionbubble.dart'; import 'package:moxxyv2/ui/widgets/chat/reactions/preview.dart';
import 'package:swipeable_tile/swipeable_tile.dart'; import 'package:swipeable_tile/swipeable_tile.dart';
class RawChatBubble extends StatelessWidget { class RawChatBubble extends StatelessWidget {
@@ -53,8 +52,9 @@ class RawChatBubble extends StatelessWidget {
/// Specified when the message bubble should not have color /// Specified when the message bubble should not have color
bool _shouldNotColorBubble() { bool _shouldNotColorBubble() {
var isInlinedWidget = false; var isInlinedWidget = false;
if (message.mediaType != null) { if (message.isMedia) {
isInlinedWidget = message.mediaType!.startsWith('image/'); isInlinedWidget =
message.fileMetadata!.mimeType?.startsWith('image/') ?? false;
} }
// Check if it is a pseudo message // Check if it is a pseudo message
@@ -63,12 +63,14 @@ class RawChatBubble extends StatelessWidget {
} }
// Check if it is an embedded file // Check if it is an embedded file
if (message.isMedia && message.mediaUrl != null && isInlinedWidget) { if (message.isMedia &&
message.fileMetadata!.path != null &&
isInlinedWidget) {
return true; return true;
} }
// Stickers are also not colored // Stickers are also not colored
return message.stickerPackId != null && message.stickerHashKey != null; return message.stickerPackId != null;
} }
Color _getBubbleColor(BuildContext context) { Color _getBubbleColor(BuildContext context) {
@@ -114,6 +116,8 @@ class RawChatBubble extends StatelessWidget {
maxWidth, maxWidth,
borderRadius, borderRadius,
sentBySelf, sentBySelf,
borderRadius.topLeft.x,
borderRadius.topRight.x,
), ),
), ),
), ),
@@ -129,7 +133,6 @@ class ChatBubble extends StatefulWidget {
required this.onSwipedCallback, required this.onSwipedCallback,
required this.bubble, required this.bubble,
this.onLongPressed, this.onLongPressed,
this.onReactionTap,
super.key, super.key,
}); });
final Message message; final Message message;
@@ -142,8 +145,6 @@ class ChatBubble extends StatefulWidget {
final GestureLongPressStartCallback? onLongPressed; final GestureLongPressStartCallback? onLongPressed;
// The actual message bubble // The actual message bubble
final RawChatBubble bubble; final RawChatBubble bubble;
// For acting on reaction taps
final void Function(Reaction)? onReactionTap;
@override @override
ChatBubbleState createState() => ChatBubbleState(); ChatBubbleState createState() => ChatBubbleState();
@@ -165,33 +166,6 @@ class ChatBubbleState extends State<ChatBubble>
: SwipeDirection.startToEnd; : SwipeDirection.startToEnd;
} }
Widget _buildReactions() {
if (widget.message.reactions.isEmpty) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.only(top: 1),
child: Wrap(
spacing: 1,
runSpacing: 2,
children: widget.message.reactions
.map(
(reaction) => ReactionBubble(
emoji: reaction.emoji,
reactions: reaction.reactions,
reactedTo: reaction.reactedBySelf,
sentBySelf: widget.sentBySelf,
onTap: widget.onReactionTap != null
? () => widget.onReactionTap!(reaction)
: null,
),
)
.toList(),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
super.build(context); super.build(context);
@@ -267,17 +241,36 @@ class ChatBubbleState extends State<ChatBubble>
child: Align( child: Align(
alignment: alignment:
widget.sentBySelf ? Alignment.centerRight : Alignment.centerLeft, widget.sentBySelf ? Alignment.centerRight : Alignment.centerLeft,
child: Column( child: Stack(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: widget.sentBySelf
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [ children: [
GestureDetector( Positioned(
onLongPressStart: widget.onLongPressed, bottom: 10,
child: widget.bubble, right: widget.sentBySelf ? 0 : null,
left: widget.sentBySelf ? null : 0,
child: ReactionsPreview(widget.message, widget.sentBySelf),
),
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: widget.sentBySelf
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
GestureDetector(
onLongPressStart: widget.onLongPressed,
child: widget.bubble,
),
if (widget.message.reactionsPreview.isNotEmpty)
// This SizedBox ensures that we have a proper bottom padding for the
// reaction preview, but also ensure that the Stack is wide enough
// so that the preview is not clipped by the Stack, since the overflow
// does not receive input events.
// See https://github.com/flutter/flutter/issues/19445
SizedBox(
height: 40,
width: MediaQuery.of(context).size.width,
),
],
), ),
_buildReactions(),
], ],
), ),
), ),

View File

@@ -1,6 +1,5 @@
import 'dart:math'; import 'dart:math';
import 'dart:ui'; import 'dart:ui';
import 'package:moxplatform/moxplatform.dart'; import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.dart'; import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
@@ -8,8 +7,8 @@ import 'package:moxxyv2/shared/models/message.dart';
/// Calculate the transformed size of a media message based on its stored /// Calculate the transformed size of a media message based on its stored
/// dimensions. /// dimensions.
Size getMediaSize(Message message, double maxWidth) { Size getMediaSize(Message message, double maxWidth) {
final mediaWidth = message.mediaWidth?.toDouble(); final mediaWidth = message.fileMetadata?.width?.toDouble();
final mediaHeight = message.mediaHeight?.toDouble(); final mediaHeight = message.fileMetadata?.height?.toDouble();
var width = maxWidth; var width = maxWidth;
var height = maxWidth; var height = maxWidth;

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:moxxyv2/shared/models/media.dart'; import 'package:moxxyv2/shared/models/file_metadata.dart';
import 'package:moxxyv2/shared/models/message.dart'; import 'package:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/helpers.dart'; import 'package:moxxyv2/ui/helpers.dart';
import 'package:moxxyv2/ui/widgets/chat/message/audio.dart'; import 'package:moxxyv2/ui/widgets/chat/message/audio.dart';
import 'package:moxxyv2/ui/widgets/chat/message/file.dart'; import 'package:moxxyv2/ui/widgets/chat/message/file.dart';
@@ -37,7 +38,7 @@ MessageType getMessageType(Message message) {
return MessageType.sticker; return MessageType.sticker;
} }
final mime = message.mediaType; final mime = message.fileMetadata!.mimeType;
if (mime == null) return MessageType.file; if (mime == null) return MessageType.file;
if (mime.startsWith('image/')) { if (mime.startsWith('image/')) {
@@ -60,6 +61,8 @@ Widget buildMessageWidget(
double maxWidth, double maxWidth,
BorderRadius radius, BorderRadius radius,
bool sent, bool sent,
double topLeftRadius,
double topRightRadius,
) { ) {
// Retracted messages are always rendered as a text message // Retracted messages are always rendered as a text message
if (message.isRetracted) { if (message.isRetracted) {
@@ -67,7 +70,12 @@ Widget buildMessageWidget(
message, message,
sent, sent,
topWidget: message.quotes != null topWidget: message.quotes != null
? buildQuoteMessageWidget(message.quotes!, sent) ? buildQuoteMessageWidget(
message.quotes!,
sent,
topLeftRadius,
topRightRadius,
)
: null, : null,
); );
} }
@@ -79,7 +87,12 @@ Widget buildMessageWidget(
message, message,
sent, sent,
topWidget: message.quotes != null topWidget: message.quotes != null
? buildQuoteMessageWidget(message.quotes!, sent) ? buildQuoteMessageWidget(
message.quotes!,
sent,
topLeftRadius,
topRightRadius,
)
: null, : null,
); );
} }
@@ -88,7 +101,19 @@ Widget buildMessageWidget(
case MessageType.video: case MessageType.video:
return VideoChatWidget(message, radius, maxWidth, sent); return VideoChatWidget(message, radius, maxWidth, sent);
case MessageType.sticker: case MessageType.sticker:
return StickerChatWidget(message, radius, maxWidth, sent); return StickerChatWidget(
message,
maxWidth,
sent,
quotedMessage: message.quotes != null
? buildQuoteMessageWidget(
message.quotes!,
sent,
radiusLargeSize,
radiusLargeSize,
)
: null,
);
case MessageType.audio: case MessageType.audio:
return AudioChatWidget(message, radius, maxWidth, sent); return AudioChatWidget(message, radius, maxWidth, sent);
case MessageType.file: case MessageType.file:
@@ -99,48 +124,86 @@ Widget buildMessageWidget(
/// Build a widget that represents a quoted message within another bubble. /// Build a widget that represents a quoted message within another bubble.
Widget buildQuoteMessageWidget( Widget buildQuoteMessageWidget(
Message message, Message message,
bool sent, { bool sent,
double topLeftRadius,
double topRightRadius, {
void Function()? resetQuote, void Function()? resetQuote,
}) { }) {
switch (getMessageType(message)) { switch (getMessageType(message)) {
case MessageType.sticker: case MessageType.sticker:
return QuotedStickerWidget(message, sent, resetQuote: resetQuote); return QuotedStickerWidget(
message,
sent,
topLeftRadius,
topRightRadius,
resetQuote: resetQuote,
);
case MessageType.text: case MessageType.text:
return QuotedTextWidget(message, sent, resetQuote: resetQuote); return QuotedTextWidget(
message,
sent,
topLeftRadius,
topRightRadius,
resetQuote: resetQuote,
);
case MessageType.image: case MessageType.image:
return QuotedImageWidget(message, sent, resetQuote: resetQuote); return QuotedImageWidget(
message,
sent,
topLeftRadius,
topRightRadius,
resetQuote: resetQuote,
);
case MessageType.video: case MessageType.video:
return QuotedVideoWidget(message, sent, resetQuote: resetQuote); return QuotedVideoWidget(
message,
sent,
topLeftRadius,
topRightRadius,
resetQuote: resetQuote,
);
case MessageType.audio: case MessageType.audio:
return QuotedAudioWidget(message, sent, resetQuote: resetQuote); return QuotedAudioWidget(
message,
sent,
topLeftRadius,
topRightRadius,
resetQuote: resetQuote,
);
case MessageType.file: case MessageType.file:
return QuotedFileWidget(message, sent, resetQuote: resetQuote); return QuotedFileWidget(
message,
sent,
topLeftRadius,
topRightRadius,
resetQuote: resetQuote,
);
} }
} }
Widget buildSharedMediaWidget(SharedMedium medium, String conversationJid) { Widget buildSharedMediaWidget(FileMetadata metadata, String conversationJid) {
if (medium.mime!.startsWith('image/')) { if (metadata.mimeType!.startsWith('image/')) {
return SharedImageWidget( return SharedImageWidget(
medium.path, metadata.path!,
onTap: () => openFile(medium.path), onTap: () => openFile(metadata.path!),
); );
} else if (medium.mime!.startsWith('video/')) { } else if (metadata.mimeType!.startsWith('video/')) {
return SharedVideoWidget( return SharedVideoWidget(
medium.path, metadata.path!,
conversationJid, conversationJid,
medium.mime!, metadata.mimeType!,
onTap: () => openFile(medium.path), onTap: () => openFile(metadata.path!),
child: const PlayButton(size: 32), child: const PlayButton(size: 32),
); );
} else if (medium.mime!.startsWith('audio/')) { } else if (metadata.mimeType!.startsWith('audio/')) {
return SharedAudioWidget( return SharedAudioWidget(
medium.path, metadata.path!,
onTap: () => openFile(medium.path), onTap: () => openFile(metadata.path!),
); );
} }
return SharedFileWidget( return SharedFileWidget(
medium.path, metadata.path!,
onTap: () => openFile(medium.path), onTap: () => openFile(metadata.path!),
); );
} }

View File

@@ -139,7 +139,7 @@ class AudioChatState extends State<AudioChatWidget> {
Future<void> _init() async { Future<void> _init() async {
_audioFile = Audio.loadFromAbsolutePath( _audioFile = Audio.loadFromAbsolutePath(
widget.message.mediaUrl!, widget.message.fileMetadata!.path!,
onDuration: (double seconds) { onDuration: (double seconds) {
setState(() { setState(() {
_duration = seconds; _duration = seconds;
@@ -251,11 +251,11 @@ class AudioChatState extends State<AudioChatWidget> {
Widget _buildDownloadable() { Widget _buildDownloadable() {
return FileChatBaseWidget( return FileChatBaseWidget(
widget.message, widget.message,
widget.message.filename!, widget.message.fileMetadata!.filename,
widget.radius, widget.radius,
widget.maxWidth, widget.maxWidth,
widget.sent, widget.sent,
mimeType: widget.message.mediaType, mimeType: widget.message.fileMetadata!.mimeType,
downloadButton: DownloadButton( downloadButton: DownloadButton(
onPressed: () { onPressed: () {
MoxplatformPlugin.handler.getDataSender().sendData( MoxplatformPlugin.handler.getDataSender().sendData(
@@ -276,8 +276,8 @@ class AudioChatState extends State<AudioChatWidget> {
} }
// TODO(PapaTutuWawa): Maybe use an async builder // TODO(PapaTutuWawa): Maybe use an async builder
if (widget.message.mediaUrl != null && if (widget.message.fileMetadata!.path != null &&
File(widget.message.mediaUrl!).existsSync()) { File(widget.message.fileMetadata!.path!).existsSync()) {
return _buildAudio(); return _buildAudio();
} }

View File

@@ -123,11 +123,11 @@ class FileChatWidget extends StatelessWidget {
Widget _buildNonDownloaded() { Widget _buildNonDownloaded() {
return FileChatBaseWidget( return FileChatBaseWidget(
message, message,
message.filename!, message.fileMetadata!.filename,
radius, radius,
maxWidth, maxWidth,
sent, sent,
mimeType: message.mediaType, mimeType: message.fileMetadata!.mimeType,
downloadButton: DownloadButton( downloadButton: DownloadButton(
onPressed: () { onPressed: () {
MoxplatformPlugin.handler.getDataSender().sendData( MoxplatformPlugin.handler.getDataSender().sendData(
@@ -142,11 +142,11 @@ class FileChatWidget extends StatelessWidget {
Widget _buildDownloading() { Widget _buildDownloading() {
return FileChatBaseWidget( return FileChatBaseWidget(
message, message,
message.filename!, message.fileMetadata!.filename,
radius, radius,
maxWidth, maxWidth,
sent, sent,
mimeType: message.mediaType, mimeType: message.fileMetadata!.filename,
downloadButton: ProgressWidget(id: message.id), downloadButton: ProgressWidget(id: message.id),
); );
} }
@@ -154,20 +154,20 @@ class FileChatWidget extends StatelessWidget {
Widget _buildInner() { Widget _buildInner() {
return FileChatBaseWidget( return FileChatBaseWidget(
message, message,
message.filename!, message.fileMetadata!.filename,
radius, radius,
maxWidth, maxWidth,
sent, sent,
mimeType: message.mediaType, mimeType: message.fileMetadata!.mimeType,
onTap: () { onTap: () {
openFile(message.mediaUrl!); openFile(message.fileMetadata!.path!);
}, },
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!message.isDownloading && message.mediaUrl != null) { if (!message.isDownloading && message.fileMetadata!.path != null) {
return _buildInner(); return _buildInner();
} }
if (message.isFileUploadNotification || message.isDownloading) { if (message.isFileUploadNotification || message.isDownloading) {

View File

@@ -27,7 +27,7 @@ class ImageChatWidget extends StatelessWidget {
Widget _buildUploading() { Widget _buildUploading() {
return MediaBaseChatWidget( return MediaBaseChatWidget(
Image.file(File(message.mediaUrl!)), Image.file(File(message.fileMetadata!.path!)),
MessageBubbleBottom(message, sent), MessageBubbleBottom(message, sent),
radius, radius,
extra: ProgressWidget(id: message.id), extra: ProgressWidget(id: message.id),
@@ -35,7 +35,7 @@ class ImageChatWidget extends StatelessWidget {
} }
Widget _buildDownloading() { Widget _buildDownloading() {
if (message.thumbnailData != null) { if (message.fileMetadata!.thumbnailData != null) {
final size = getMediaSize(message, maxWidth); final size = getMediaSize(message, maxWidth);
return MediaBaseChatWidget( return MediaBaseChatWidget(
@@ -43,7 +43,7 @@ class ImageChatWidget extends StatelessWidget {
width: size.width, width: size.width,
height: size.height, height: size.height,
child: BlurHash( child: BlurHash(
hash: message.thumbnailData!, hash: message.fileMetadata!.thumbnailData!,
decodingWidth: size.width.toInt(), decodingWidth: size.width.toInt(),
decodingHeight: size.height.toInt(), decodingHeight: size.height.toInt(),
), ),
@@ -55,11 +55,11 @@ class ImageChatWidget extends StatelessWidget {
} else { } else {
return FileChatBaseWidget( return FileChatBaseWidget(
message, message,
message.filename!, message.fileMetadata!.filename,
radius, radius,
maxWidth, maxWidth,
sent, sent,
mimeType: message.mediaType, mimeType: message.fileMetadata!.mimeType,
downloadButton: ProgressWidget(id: message.id), downloadButton: ProgressWidget(id: message.id),
); );
} }
@@ -70,21 +70,23 @@ class ImageChatWidget extends StatelessWidget {
final size = getMediaSize(message, maxWidth); final size = getMediaSize(message, maxWidth);
Widget image; Widget image;
if (message.mediaWidth != null && message.mediaHeight != null) { if (message.fileMetadata!.width != null &&
message.fileMetadata!.height != null) {
image = SizedBox( image = SizedBox(
width: size.width, width: size.width,
height: size.height, height: size.height,
child: Image.file( child: Image.file(
File(message.mediaUrl!), File(message.fileMetadata!.path!),
cacheWidth: size.width.toInt(), cacheWidth: size.width.toInt(),
cacheHeight: size.height.toInt(), cacheHeight: size.height.toInt(),
), ),
); );
} else { } else {
// TODO(Unknown): Somehow have sensible defaults here
image = Image.file( image = Image.file(
File(message.mediaUrl!), File(message.fileMetadata!.path!),
cacheWidth: size.width.toInt(), // cacheWidth: size.width.toInt(),
cacheHeight: size.height.toInt(), // cacheHeight: size.height.toInt(),
); );
} }
@@ -95,12 +97,12 @@ class ImageChatWidget extends StatelessWidget {
sent, sent,
), ),
radius, radius,
onTap: () => openFile(message.mediaUrl!), onTap: () => openFile(message.fileMetadata!.path!),
); );
} }
Widget _buildDownloadable() { Widget _buildDownloadable() {
if (message.thumbnailData != null) { if (message.fileMetadata!.thumbnailData != null) {
final size = getMediaSize(message, maxWidth); final size = getMediaSize(message, maxWidth);
return MediaBaseChatWidget( return MediaBaseChatWidget(
@@ -108,7 +110,7 @@ class ImageChatWidget extends StatelessWidget {
width: size.width, width: size.width,
height: size.height, height: size.height,
child: BlurHash( child: BlurHash(
hash: message.thumbnailData!, hash: message.fileMetadata!.thumbnailData!,
decodingWidth: size.width.toInt(), decodingWidth: size.width.toInt(),
decodingHeight: size.height.toInt(), decodingHeight: size.height.toInt(),
), ),
@@ -122,11 +124,11 @@ class ImageChatWidget extends StatelessWidget {
} else { } else {
return FileChatBaseWidget( return FileChatBaseWidget(
message, message,
message.filename!, message.fileMetadata!.filename,
radius, radius,
maxWidth, maxWidth,
sent, sent,
mimeType: message.mediaType, mimeType: message.fileMetadata!.mimeType,
downloadButton: DownloadButton( downloadButton: DownloadButton(
onPressed: () { onPressed: () {
MoxplatformPlugin.handler.getDataSender().sendData( MoxplatformPlugin.handler.getDataSender().sendData(
@@ -149,7 +151,8 @@ class ImageChatWidget extends StatelessWidget {
} }
// TODO(PapaTutuWawa): Maybe use an async builder // TODO(PapaTutuWawa): Maybe use an async builder
if (message.mediaUrl != null && File(message.mediaUrl!).existsSync()) { if (message.fileMetadata!.path != null &&
File(message.fileMetadata!.path!).existsSync()) {
return _buildImage(); return _buildImage();
} }

Some files were not shown because too many files have changed in this diff Show More