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
# Build scripts
release/
release-*/

View File

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

View File

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

View File

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

View File

@@ -265,14 +265,14 @@ files:
messages:
type: List<Message>
deserialise: true
# Returned by [GetPagedSharedMediaCommand]
- name: PagedSharedMediaResultEvent
# Returned by [GetReactionsForMessageCommand]
- name: ReactionsForMessageResult
extends: BackgroundEvent
implements:
- JsonImplementation
attributes:
media:
type: List<SharedMedium>
reactions:
type: List<ReactionGroup>
deserialise: true
generate_builder: true
builder_name: "Event"
@@ -527,9 +527,13 @@ files:
implements:
- JsonImplementation
attributes:
stickerPackId: String
stickerHashKey: String
sticker:
type: Sticker
deserialise: true
recipient: String
quotes:
type: Message?
deserialise: true
- name: FetchStickerPackCommand
extends: BackgroundCommand
implements:
@@ -566,6 +570,12 @@ files:
conversationJid: String
olderThan: bool
timestamp: int?
- name: GetReactionsForMessageCommand
extends: BackgroundCommand
implements:
- JsonImplementation
attributes:
messageId: int
generate_builder: true
# get${builder_Name}FromJson
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/stickers.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/sticker_pack.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,
),
);
case sharedMediaRoute:
return SharedMediaPage.getRoute(
settings.arguments! as SharedMediaPageArguments,
);
// case sharedMediaRoute:
// return SharedMediaPage.getRoute(
// settings.arguments! as SharedMediaPageArguments,
// );
case blocklistRoute:
return BlocklistPage.route;
case profileRoute:
return ProfilePage.route;
return ProfilePage.getRoute(
settings.arguments! as ProfileArguments,
);
case settingsRoute:
return SettingsPage.route;
case aboutRoute:

View File

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

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/events.dart';
@@ -15,6 +16,23 @@ class BlocklistService {
bool? _supported;
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() {
// Invalidate the caches
_blocklist = null;
@@ -49,10 +67,9 @@ class BlocklistService {
// Diff the received blocklist with the cache
final newItems = List<String>.empty(growable: true);
final removedItems = List<String>.empty(growable: true);
final db = GetIt.I.get<DatabaseService>();
for (final item in blocklist) {
if (!_blocklist!.contains(item)) {
await db.addBlocklistEntry(item);
await _addBlocklistEntry(item);
_blocklist!.add(item);
newItems.add(item);
}
@@ -61,7 +78,7 @@ class BlocklistService {
// Diff the cache with the received blocklist
for (final item in _blocklist!) {
if (!blocklist.contains(item)) {
await db.removeBlocklistEntry(item);
await _removeBlocklistEntry(item);
_blocklist!.remove(item);
removedItems.add(item);
}
@@ -83,7 +100,9 @@ class BlocklistService {
/// Returns the blocklist from the database
Future<List<String>> getBlocklist() async {
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) {
unawaited(_requestBlocklist());
@@ -120,7 +139,7 @@ class BlocklistService {
_blocklist!.add(item);
newBlocks.add(item);
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item);
await _addBlocklistEntry(item);
}
break;
case BlockPushType.unblock:
@@ -128,7 +147,7 @@ class BlocklistService {
_blocklist!.removeWhere((i) => i == item);
removedBlocks.add(item);
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item);
await _removeBlocklistEntry(item);
}
break;
}
@@ -150,7 +169,7 @@ class BlocklistService {
}
_blocklist!.add(jid);
await GetIt.I.get<DatabaseService>().addBlocklistEntry(jid);
await _addBlocklistEntry(jid);
return GetIt.I
.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
@@ -165,7 +184,7 @@ class BlocklistService {
}
_blocklist!.remove(jid);
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(jid);
await _removeBlocklistEntry(jid);
return GetIt.I
.get<XmppConnection>()
.getManagerById<BlockingManager>(blockingManager)!
@@ -182,7 +201,8 @@ class BlocklistService {
}
_blocklist!.clear();
await GetIt.I.get<DatabaseService>().removeAllBlocklistEntries();
await GetIt.I.get<DatabaseService>().database.delete(blocklistTable);
return GetIt.I
.get<XmppConnection>()
.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:logging/logging.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/preferences.dart';
import 'package:moxxyv2/service/roster.dart';
@@ -122,7 +123,15 @@ class ContactsService {
Future<Map<String, String>> _getContactIds() async {
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!;
}
@@ -165,7 +174,6 @@ class ContactsService {
}
Future<void> scanContacts() async {
final db = GetIt.I.get<DatabaseService>();
final cs = GetIt.I.get<ConversationService>();
final rs = GetIt.I.get<RosterService>();
final contacts = await _fetchContactsWithJabber();
@@ -183,7 +191,12 @@ class ContactsService {
if (index != -1) continue;
final jid = knownContactIdsReverse[id]!;
await db.removeContactId(id);
await GetIt.I.get<DatabaseService>().database.delete(
contactsTable,
where: 'id = ?',
whereArgs: [id],
);
_contactIds!.remove(knownContactIdsReverse[id]);
// Remove the avatar file, if it existed
@@ -235,7 +248,13 @@ class ContactsService {
for (final contact in contacts) {
// Add the ID to the cache and the database if it does not already exist
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;
}

View File

@@ -1,8 +1,12 @@
import 'package:get_it/get_it.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/helpers.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/not_specified.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/message.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
/// to the cache.
Future<void> _loadConversationsIfNeeded() async {
if (_conversationCache != null) return;
final conversations =
await GetIt.I.get<DatabaseService>().loadConversations();
final conversations = await loadConversations();
_conversationCache = Map<String, Conversation>.fromEntries(
conversations.map((c) => MapEntry(c.jid, c)),
);
@@ -87,7 +126,8 @@ class ConversationService {
_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
/// [ConversationService.createOrUpdateConversation].
Future<Conversation> updateConversation(
@@ -103,24 +143,57 @@ class ConversationService {
Object? contactId = notSpecified,
Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified,
int? sharedMediaAmount,
}) async {
final conversation = (await _getConversationByJid(jid))!;
var newConversation =
await GetIt.I.get<DatabaseService>().updateConversation(
jid,
lastMessage: lastMessage,
lastChangeTimestamp: lastChangeTimestamp,
open: open,
unreadCounter: unreadCounter,
avatarUrl: avatarUrl,
chatState: conversation.chatState,
muted: muted,
encrypted: encrypted,
contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
sharedMediaAmount: sharedMediaAmount,
final c = <String, dynamic>{};
if (lastMessage != null) {
c['lastMessageId'] = lastMessage.id;
}
if (lastChangeTimestamp != null) {
c['lastChangeTimestamp'] = lastChangeTimestamp;
}
if (open != null) {
c['open'] = boolToInt(open);
}
if (unreadCounter != null) {
c['unreadCounter'] = unreadCounter;
}
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
@@ -133,8 +206,9 @@ class ConversationService {
return newConversation;
}
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the
/// cache.
/// Creates a [Conversation] inside the database given the data. This is so that the
/// [Conversation] object can carry its database id.
///
/// To prevent issues with the cache, only call from within
/// [ConversationService.createOrUpdateConversation].
Future<Conversation> addConversationFromData(
@@ -148,27 +222,33 @@ class ConversationService {
bool open,
bool muted,
bool encrypted,
int sharedMediaAmount,
String? contactId,
String? contactAvatarPath,
String? contactDisplayName,
) async {
final newConversation =
await GetIt.I.get<DatabaseService>().addConversationFromData(
final rosterItem =
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
final newConversation = Conversation(
title,
lastMessage,
type,
avatarUrl,
jid,
unreadCounter,
type,
lastChangeTimestamp,
open,
rosterItem != null && !rosterItem.pseudoRosterItem,
rosterItem?.subscription ?? 'none',
muted,
encrypted,
sharedMediaAmount,
contactId,
contactAvatarPath,
contactDisplayName,
ChatState.gone,
contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
);
await GetIt.I.get<DatabaseService>().database.insert(
conversationsTable,
newConversation.toDatabaseJson(),
);
if (_conversationCache != null) {

View File

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

View File

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

View File

@@ -18,8 +18,7 @@ Future<void> createDatabase(Database db, int version) async {
);
// Messages
await db.execute(
'''
await db.execute('''
CREATE TABLE $messagesTable (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender TEXT NOT NULL,
@@ -27,41 +26,75 @@ Future<void> createDatabase(Database db, int version) async {
timestamp INTEGER NOT NULL,
sid TEXT NOT NULL,
conversationJid TEXT NOT NULL,
isMedia INTEGER NOT NULL,
isFileUploadNotification INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
errorType INTEGER,
warningType INTEGER,
mediaUrl TEXT,
mediaType TEXT,
thumbnailData TEXT,
mediaWidth INTEGER,
mediaHeight INTEGER,
srcUrl TEXT,
key TEXT,
iv TEXT,
encryptionScheme TEXT,
received INTEGER,
displayed INTEGER,
acked INTEGER,
originId TEXT,
quote_id INTEGER,
filename TEXT,
plaintextHashes TEXT,
ciphertextHashes TEXT,
file_metadata_id TEXT,
isDownloading INTEGER NOT NULL,
isUploading INTEGER NOT NULL,
mediaSize INTEGER,
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(
'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
@@ -78,7 +111,6 @@ Future<void> createDatabase(Database db, int version) async {
muted INTEGER NOT NULL,
encrypted INTEGER NOT NULL,
lastMessageId INTEGER,
sharedMediaAmount INTEGER NOT NULL,
contactId TEXT,
contactAvatarPath TEXT,
contactDisplayName TEXT,
@@ -87,6 +119,9 @@ Future<void> createDatabase(Database db, int version) async {
ON DELETE SET NULL
)''',
);
await db.execute(
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
);
// Contacts
await db.execute('''
@@ -95,21 +130,6 @@ Future<void> createDatabase(Database db, int version) async {
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
await db.execute(
'''
@@ -134,19 +154,14 @@ Future<void> createDatabase(Database db, int version) async {
await db.execute(
'''
CREATE TABLE $stickersTable (
hashKey TEXT PRIMARY KEY,
mediaType TEXT NOT NULL,
id TEXT PRIMARY KEY,
desc TEXT NOT NULL,
size INTEGER NOT NULL,
width INTEGER,
height INTEGER,
hashes TEXT NOT NULL,
urlSources TEXT NOT NULL,
path TEXT NOT NULL,
stickerPackId 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
ON DELETE CASCADE,
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
)''',
);
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/service/avatars.dart';
import 'package:moxxyv2/service/blocking.dart';
import 'package:moxxyv2/service/connectivity.dart';
import 'package:moxxyv2/service/contacts.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/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/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.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/helpers.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/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_pack.dart' as sticker_pack;
import 'package:moxxyv2/shared/models/xmpp_state.dart';
import 'package:moxxyv2/shared/synchronized_queue.dart';
import 'package:permission_handler/permission_handler.dart';
//import 'package:permission_handler/permission_handler.dart';
void setupBackgroundEventHandler() {
final handler = EventHandler()
@@ -97,6 +99,7 @@ void setupBackgroundEventHandler() {
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
]);
GetIt.I.registerSingleton<EventHandler>(handler);
@@ -151,17 +154,18 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
// Check some permissions
final storagePerm = await Permission.storage.status;
final permissions = List<int>.empty(growable: true);
if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
permissions.add(Permission.storage.value);
// TODO(Unknown): Do we still need this permission?
// final storagePerm = await Permission.storage.status;
// final permissions = List<int>.empty(growable: true);
// if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
// permissions.add(Permission.storage.value);
await xss.modifyXmppState(
(state) => state.copyWith(
askedStoragePermission: true,
),
);
}
// await xss.modifyXmppState(
// (state) => state.copyWith(
// askedStoragePermission: true,
// ),
// );
// }
return PreStartDoneEvent(
state: 'logged_in',
@@ -169,9 +173,10 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
displayName: state.displayName ?? state.jid!.split('@').first,
avatarUrl: state.avatarUrl,
avatarHash: state.avatarHash,
permissionsToRequest: permissions,
permissionsToRequest: [],
preferences: preferences,
conversations: (await GetIt.I.get<DatabaseService>().loadConversations())
conversations:
(await GetIt.I.get<ConversationService>().loadConversations())
.where((c) => c.open)
.toList(),
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
@@ -240,7 +245,6 @@ Future<void> performAddConversation(
true,
preferences.defaultMuteState,
preferences.enableOmemoByDefault,
0,
contactId,
await css.getProfilePicturePathForJid(command.jid),
await css.getContactDisplayName(contactId),
@@ -403,7 +407,9 @@ Future<void> performSetPreferences(
final pm = GetIt.I
.get<XmppConnection>()
.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 &&
!oldPrefs.isStickersNodePublic) {
// Set to open
@@ -471,7 +477,6 @@ Future<void> performAddContact(
true,
prefs.defaultMuteState,
prefs.enableOmemoByDefault,
0,
contactId,
await css.getProfilePicturePathForJid(jid),
await css.getContactDisplayName(contactId),
@@ -562,26 +567,33 @@ Future<void> performRequestDownload(
);
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
// NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails
// 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);
await srv.downloadFile(
FileDownloadJob(
MediaFileLocation(
message.srcUrl!,
message.filename ?? filenameFromUrl(message.srcUrl!),
message.encryptionScheme,
message.key != null ? base64Decode(message.key!) : null,
message.iv != null ? base64Decode(message.iv!) : null,
message.plaintextHashes,
message.ciphertextHashes,
fileMetadata.sourceUrls!,
fileMetadata.filename,
fileMetadata.encryptionScheme,
fileMetadata.encryptionKey != null
? base64Decode(fileMetadata.encryptionKey!)
: null,
fileMetadata.encryptionIv != null
? base64Decode(fileMetadata.encryptionIv!)
: null,
fileMetadata.plaintextHashes,
fileMetadata.ciphertextHashes,
null,
),
message.id,
message.fileMetadata!.id,
message.conversationJid,
mimeGuess,
),
@@ -655,6 +667,9 @@ Future<void> performSendChatState(
// Only send chat states if the users wants to send them
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>();
if (command.jid != '') {
@@ -927,34 +942,32 @@ Future<void> performAddMessageReaction(
AddReactionToMessageCommand command, {
dynamic extra,
}) async {
final ms = GetIt.I.get<MessageService>();
final conn = GetIt.I.get<XmppConnection>();
final msg =
await ms.getMessageById(command.conversationJid, command.messageId);
assert(msg != null, 'The message must be found');
// Update the state
final reactions = List<Reaction>.from(msg!.reactions);
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);
final rs = GetIt.I.get<ReactionsService>();
final msg = await rs.addNewReaction(
command.messageId,
command.conversationJid,
command.emoji,
);
if (msg == null) {
return;
}
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 != '') {
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
// Send the reaction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!
.sendMessage(
MessageDetails(
to: command.conversationJid,
messageReactions: MessageReactions(
msg.originId ?? msg.sid,
ownReactions,
await rs.getReactionsForMessageByJid(
command.messageId,
jid,
),
),
requestChatMarkers: false,
messageProcessingHints:
@@ -968,35 +981,32 @@ Future<void> performRemoveMessageReaction(
RemoveReactionFromMessageCommand command, {
dynamic extra,
}) async {
final ms = GetIt.I.get<MessageService>();
final conn = GetIt.I.get<XmppConnection>();
final msg =
await ms.getMessageById(command.conversationJid, command.messageId);
assert(msg != null, 'The message must be found');
// Update the state
final reactions = List<Reaction>.from(msg!.reactions);
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);
final rs = GetIt.I.get<ReactionsService>();
final msg = await rs.removeReaction(
command.messageId,
command.conversationJid,
command.emoji,
);
if (msg == null) {
return;
}
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 != '') {
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
// Send the reaction
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
GetIt.I
.get<XmppConnection>()
.getManagerById<MessageManager>(messageManager)!
.sendMessage(
MessageDetails(
to: command.conversationJid,
messageReactions: MessageReactions(
msg.originId ?? msg.sid,
ownReactions,
await rs.getReactionsForMessageByJid(
command.messageId,
jid,
),
),
requestChatMarkers: false,
messageProcessingHints:
@@ -1042,20 +1052,12 @@ Future<void> performSendSticker(
SendStickerCommand command, {
dynamic extra,
}) async {
final xs = GetIt.I.get<XmppService>();
final ss = GetIt.I.get<StickersService>();
final sticker = await ss.getStickerByHashKey(
command.stickerPackId,
command.stickerHashKey,
);
assert(sticker != null, 'Sticker not found');
await xs.sendMessage(
body: sticker!.desc,
await GetIt.I.get<XmppService>().sendMessage(
body: command.sticker.desc,
recipients: [command.recipient],
sticker: sticker,
sticker: command.sticker,
currentConversationJid: command.recipient,
quotedMessage: command.quotes,
);
}
@@ -1096,19 +1098,30 @@ Future<void> performFetchStickerPack(
.map(
(s) => sticker.Sticker(
'',
s.metadata.mediaType!,
command.stickerPackId,
s.metadata.desc!,
s.metadata.size!,
s.metadata.width,
s.metadata.height,
s.metadata.hashes,
s.suggests,
FileMetadata(
'',
null,
s.sources
.whereType<StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList(),
'',
command.stickerPackId,
s.suggests,
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(),
@@ -1188,15 +1201,49 @@ Future<void> performGetPagedSharedMedia(
final id = extra as String;
final result =
await GetIt.I.get<DatabaseService>().getPaginatedSharedMediaForJid(
await GetIt.I.get<MessageService>().getPaginatedSharedMediaMessagesForJid(
command.conversationJid,
command.olderThan,
command.timestamp,
);
sendEvent(
PagedSharedMediaResultEvent(
media: result,
PagedMessagesResultEvent(
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,
);

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;
}
String getStickerHashKeyType(Map<String, String> hashes) {
if (hashes.containsKey('blake2b-512')) {
return 'blake2b-512';
} else if (hashes.containsKey('blake2b-512')) {
return 'blake2b-256';
} else if (hashes.containsKey('sha3-512')) {
return 'sha3-512';
} else if (hashes.containsKey('sha3-256')) {
return 'sha3-256';
} else if (hashes.containsKey('sha3-256')) {
return 'sha-512';
} else if (hashes.containsKey('sha-256')) {
return 'sha-256';
HashFunction getStickerHashKeyType(Map<HashFunction, String> hashes) {
if (hashes.containsKey(HashFunction.blake2b512)) {
return HashFunction.blake2b512;
} else if (hashes.containsKey(HashFunction.blake2b256)) {
return HashFunction.blake2b256;
} else if (hashes.containsKey(HashFunction.sha3_512)) {
return HashFunction.sha3_512;
} else if (hashes.containsKey(HashFunction.sha3_256)) {
return HashFunction.sha3_256;
} else if (hashes.containsKey(HashFunction.sha512)) {
return HashFunction.sha512;
} else if (hashes.containsKey(HashFunction.sha256)) {
return HashFunction.sha256;
}
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);
return '$key:${hashes[key]}';
}
@@ -131,16 +132,19 @@ String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
if (quotedMessage.isMedia) {
// Create formatted size string, if size is stored
String quoteMessageSize;
if (quotedMessage.mediaSize != null && quotedMessage.mediaSize! > 0) {
quoteMessageSize = '(${fileSizeToString(quotedMessage.mediaSize!)}) ';
if (quotedMessage.fileMetadata!.size != null &&
quotedMessage.fileMetadata!.size! > 0) {
quoteMessageSize =
'(${fileSizeToString(quotedMessage.fileMetadata!.size!)}) ';
} else {
quoteMessageSize = '';
}
// Create media url string, or use body if no srcUrl is stored
String quotedMediaUrl;
if (quotedMessage.srcUrl != null && quotedMessage.srcUrl!.isNotEmpty) {
quotedMediaUrl = '${quotedMessage.srcUrl!}';
if (quotedMessage.fileMetadata!.sourceUrls != null &&
quotedMessage.fileMetadata!.sourceUrls!.first.isNotEmpty) {
quotedMediaUrl = '${quotedMessage.fileMetadata!.sourceUrls!.first}';
} else if (quotedMessage.body.isNotEmpty) {
quotedMediaUrl = '${quotedMessage.body}';
} 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/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].
/// 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;
}
class FileMetadata {
const FileMetadata({this.mime, this.size});
class FileUploadMetadata {
const FileUploadMetadata({this.mime, this.size});
final String? mime;
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
/// does not specify the Content-Length header, null is returned.
/// 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));
return FileMetadata(
return FileUploadMetadata(
mime: result?.contentType,
size: result?.contentLength,
);

View File

@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
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/cryptography/cryptography.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/helpers.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/notifications.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/error_types.dart';
import 'package:moxxyv2/shared/events.dart';
import 'package:moxxyv2/shared/helpers.dart';
import 'package:moxxyv2/shared/models/media.dart';
import 'package:moxxyv2/shared/warning_types.dart';
import 'package:path/path.dart' as pathlib;
import 'package:path_provider/path_provider.dart';
@@ -122,29 +121,25 @@ class HttpFileTransferService {
});
}
Future<void> _copyFile(FileUploadJob job) async {
for (final recipient in job.recipients) {
final newPath = await getDownloadPath(
pathlib.basename(job.path),
recipient,
job.mime,
);
await File(job.path).copy(newPath);
Future<void> _copyFile(
FileUploadJob job,
String to,
) async {
if (!File(to).existsSync()) {
await File(job.path).copy(to);
// Let the media scanner index the file
MoxplatformPlugin.media.scanFile(newPath);
// Update the message
await GetIt.I.get<MessageService>().updateMessage(
job.messageMap[recipient]!.id,
mediaUrl: newPath,
MoxplatformPlugin.media.scanFile(to);
} else {
_log.finest(
'Skipping file copy on upload as file is already at media location',
);
}
}
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
// Notify UI of upload failure
for (final recipient in job.recipients) {
@@ -154,6 +149,19 @@ class HttpFileTransferService {
isUploading: false,
);
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();
@@ -233,32 +241,10 @@ class HttpFileTransferService {
} else {
_log.fine('Upload was successful');
const uuid = Uuid();
for (final recipient in job.recipients) {
// Notify UI of upload completion
var msg = await ms.updateMessage(
job.messageMap[recipient]!.id,
mediaSize: stat.size,
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,
srcUrl: slot.getUrl,
);
// TODO(Unknown): Maybe batch those two together?
final oldSid = msg.sid;
msg = await ms.updateMessage(
msg.id,
sid: uuid.v4(),
originId: uuid.v4(),
);
sendEvent(MessageUpdatedEvent(message: msg));
// Get hashes
StatelessFileSharingSource source;
final plaintextHashes = <String, String>{};
final plaintextHashes = <HashFunction, String>{};
Map<HashFunction, String>? ciphertextHashes;
if (encryption != null) {
source = StatelessFileSharingEncryptedSource(
SFSEncryptionType.aes256GcmNoPadding,
@@ -269,10 +255,11 @@ class HttpFileTransferService {
);
plaintextHashes.addAll(encryption.plaintextHashes);
ciphertextHashes = encryption.ciphertextHashes;
} else {
source = StatelessFileSharingUrlSource(slot.getUrl);
try {
plaintextHashes[hashSha256] = await GetIt.I
plaintextHashes[HashFunction.sha256] = await GetIt.I
.get<CryptographyService>()
.hashFile(job.path, HashFunction.sha256);
} catch (ex) {
@@ -280,6 +267,76 @@ class HttpFileTransferService {
}
}
// 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();
for (final recipient in job.recipients) {
// Notify UI of upload completion
var msg = await ms.updateMessage(
job.messageMap[recipient]!.id,
errorType: noError,
isUploading: false,
fileMetadata: metadata,
);
// TODO(Unknown): Maybe batch those two together?
final oldSid = msg.sid;
msg = await ms.updateMessage(
msg.id,
sid: uuid.v4(),
originId: uuid.v4(),
);
sendEvent(MessageUpdatedEvent(message: msg));
// Send the message to the recipient
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
@@ -292,7 +349,7 @@ class HttpFileTransferService {
FileMetadataData(
mediaType: job.mime,
size: stat.size,
name: pathlib.basename(job.path),
name: filename,
thumbnails: job.thumbnails,
hashes: plaintextHashes,
),
@@ -305,15 +362,14 @@ class HttpFileTransferService {
_log.finest(
'Sent message with file upload for ${job.path} to $recipient',
);
final isMultiMedia = (job.mime?.startsWith('image/') ?? false) ||
(job.mime?.startsWith('video/') ?? false);
if (isMultiMedia) {
_log.finest(
'File appears to be either an image or a video. Copying it to the correct directory...',
);
unawaited(_copyFile(job));
}
// Remove the old metadata only here because we would otherwise violate a foreign key
// constraint.
if (metadataWrapper.retrieved) {
await GetIt.I.get<FilesService>().removeFileMetadata(
job.metadataId,
);
}
}
@@ -351,8 +407,10 @@ class HttpFileTransferService {
/// Actually attempt to download the file described by the job [job].
Future<void> _performFileDownload(FileDownloadJob job) async {
final filename = job.location.filename;
final downloadedPath =
await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
final downloadedPath = await computeCachedPathForFile(
job.location.filename,
job.location.plaintextHashes,
);
var downloadPath = downloadedPath;
if (job.location.key != null && job.location.iv != null) {
@@ -361,15 +419,18 @@ class HttpFileTransferService {
downloadPath = pathlib.join(tempDir.path, filename);
}
// TODO(Unknown): Maybe try other URLs?
final downloadUrl = job.location.urls.first;
_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;
var integrityCheckPassed = true;
try {
_log.finest('Beginning download...');
downloadStatusCode = await client.downloadFile(
Uri.parse(job.location.url),
Uri.parse(downloadUrl),
downloadPath,
(total, current) {
final progress = current.toDouble() / total.toDouble();
@@ -388,18 +449,15 @@ class HttpFileTransferService {
if (!isRequestOkay(downloadStatusCode)) {
_log.warning(
'HTTP GET of ${job.location.url} returned $downloadStatusCode',
'HTTP GET of $downloadUrl returned $downloadStatusCode',
);
await _fileDownloadFailed(job, fileDownloadFailedError);
return;
}
var integrityCheckPassed = true;
final conv = (await GetIt.I
.get<ConversationService>()
.getConversationByJid(job.conversationJid))!;
final decryptionKeysAvailable =
job.location.key != null && job.location.iv != null;
final crypto = GetIt.I.get<CryptographyService>();
if (decryptionKeysAvailable) {
// The file was downloaded and is now being decrypted
sendEvent(
@@ -409,10 +467,10 @@ class HttpFileTransferService {
);
try {
final result = await GetIt.I.get<CryptographyService>().decryptFile(
final result = await crypto.decryptFile(
downloadPath,
downloadedPath,
encryptionTypeFromNamespace(job.location.encryptionScheme!),
SFSEncryptionType.fromNamespace(job.location.encryptionScheme!),
job.location.key!,
job.location.iv!,
job.location.plaintextHashes ?? {},
@@ -437,6 +495,28 @@ class HttpFileTransferService {
unawaited(
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
@@ -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(
job.mId,
mediaUrl: downloadedPath,
mediaType: mime,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
mediaSize: File(downloadedPath).lengthSync(),
fileMetadata: metadata,
isFileUploadNotification: false,
warningType:
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
errorType: conv.encrypted && !decryptionKeysAvailable
errorType: conversation.encrypted && !decryptionKeysAvailable
? messageChatEncryptedButFileNot
: null,
isDownloading: false,
@@ -498,47 +598,21 @@ class HttpFileTransferService {
sendEvent(MessageUpdatedEvent(message: msg));
final sharedMedium =
await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
downloadedPath,
msg.timestamp,
conv.jid,
job.mId,
mime: mime,
final updatedConversation = conversation.copyWith(
lastMessage: conversation.lastMessage?.id == job.mId
? msg
: conversation.lastMessage,
);
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(
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);
cs.setConversation(updatedConversation);
// Show a notification
if (notification.shouldShowNotification(msg.conversationJid) &&
job.shouldShowNotification) {
_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
await _pickNextDownloadTask();

View File

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

View File

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

View File

@@ -1,17 +1,20 @@
import 'dart:async';
import 'dart:io';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxxmpp/moxxmpp.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/helpers.dart';
import 'package:moxxyv2/service/files.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/shared/cache.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.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:synchronized/synchronized.dart';
@@ -23,6 +26,97 @@ class MessageService {
LRUCache(conversationMessagePageCacheSize);
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
/// 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.
@@ -38,13 +132,109 @@ class MessageService {
if (result != null) return result;
}
final page =
await GetIt.I.get<DatabaseService>().getPaginatedMessagesForJid(
final db = GetIt.I.get<DatabaseService>().database;
final comparator = olderThan ? '<' : '>';
final query = oldestTimestamp != null
? 'conversationJid = ? AND timestamp $comparator ?'
: '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,
olderThan,
oldestTimestamp,
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) {
await _cacheLock.synchronized(() {
_messageCache.cache(
@@ -57,78 +247,129 @@ class MessageService {
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.
Future<Message> addMessageFromData(
String body,
int timestamp,
String sender,
String conversationJid,
bool isMedia,
String sid,
bool isFileUploadNotification,
bool encrypted,
bool containsNoStore, {
String? srcUrl,
String? key,
String? iv,
String? encryptionScheme,
String? mediaUrl,
String? mediaType,
String? thumbnailData,
int? mediaWidth,
int? mediaHeight,
String? originId,
String? quoteId,
String? filename,
FileMetadata? fileMetadata,
int? errorType,
int? warningType,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
bool isDownloading = false,
bool isUploading = false,
int? mediaSize,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
bool received = false,
bool displayed = false,
}) async {
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
final db = GetIt.I.get<DatabaseService>().database;
var m = Message(
sender,
body,
timestamp,
sender,
conversationJid,
isMedia,
sid,
-1,
conversationJid,
isFileUploadNotification,
encrypted,
containsNoStore,
srcUrl: srcUrl,
key: key,
iv: iv,
encryptionScheme: encryptionScheme,
mediaUrl: mediaUrl,
mediaType: mediaType,
thumbnailData: thumbnailData,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
originId: originId,
quoteId: quoteId,
filename: filename,
errorType: errorType,
warningType: warningType,
plaintextHashes: plaintextHashes,
ciphertextHashes: ciphertextHashes,
isUploading: isUploading,
isDownloading: isDownloading,
mediaSize: mediaSize,
stickerPackId: stickerPackId,
stickerHashKey: stickerHashKey,
pseudoMessageType: pseudoMessageType,
pseudoMessageData: pseudoMessageData,
fileMetadata: fileMetadata,
received: received,
displayed: displayed,
acked: false,
originId: originId,
isUploading: isUploading,
isDownloading: isDownloading,
stickerPackId: stickerPackId,
pseudoMessageType: pseudoMessageType,
pseudoMessageData: pseudoMessageData,
);
if (quoteId != null) {
final quotes = await getMessageByXmppId(quoteId, conversationJid);
if (quotes == null) {
_log.warning('Failed to add quote for message with id $quoteId');
} else {
m = m.copyWith(quotes: quotes);
}
}
m = m.copyWith(
id: await db.insert(messagesTable, m.toDatabaseJson()),
);
await _cacheLock.synchronized(() {
@@ -138,21 +379,21 @@ class MessageService {
conversationJid,
clampedListPrepend(
cachedList,
msg,
m,
messagePaginationSize,
),
);
}
});
return msg;
return m;
}
Future<Message?> getMessageByStanzaId(
String conversationJid,
String stanzaId,
) async {
return GetIt.I.get<DatabaseService>().getMessageByXmppId(
return getMessageByXmppId(
stanzaId,
conversationJid,
includeOriginId: false,
@@ -163,14 +404,7 @@ class MessageService {
String conversationJid,
String id,
) async {
return GetIt.I.get<DatabaseService>().getMessageByXmppId(
id,
conversationJid,
);
}
Future<Message?> getMessageById(String conversationJid, int id) async {
return GetIt.I.get<DatabaseService>().getMessageById(
return getMessageByXmppId(
id,
conversationJid,
);
@@ -180,58 +414,101 @@ class MessageService {
Future<Message> updateMessage(
int id, {
Object? body = notSpecified,
Object? mediaUrl = notSpecified,
Object? mediaType = notSpecified,
bool? isMedia,
bool? received,
bool? displayed,
bool? acked,
Object? fileMetadata = notSpecified,
Object? errorType = notSpecified,
Object? warningType = notSpecified,
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? isDownloading,
Object? originId = notSpecified,
Object? sid = notSpecified,
Object? thumbnailData = notSpecified,
bool? isRetracted,
bool? isEdited,
Object? reactions = notSpecified,
}) async {
final msg = await GetIt.I.get<DatabaseService>().updateMessage(
id,
body: body,
mediaUrl: mediaUrl,
mediaType: mediaType,
received: received,
displayed: displayed,
acked: acked,
errorType: errorType,
warningType: warningType,
isFileUploadNotification: isFileUploadNotification,
srcUrl: srcUrl,
key: key,
iv: iv,
encryptionScheme: encryptionScheme,
mediaWidth: mediaWidth,
mediaHeight: mediaHeight,
mediaSize: mediaSize,
isUploading: isUploading,
isDownloading: isDownloading,
originId: originId,
sid: sid,
isRetracted: isRetracted,
isMedia: isMedia,
thumbnailData: thumbnailData,
isEdited: isEdited,
reactions: reactions,
final db = GetIt.I.get<DatabaseService>().database;
final m = <String, dynamic>{};
if (body != notSpecified) {
m['body'] = body as String?;
}
if (received != null) {
m['received'] = boolToInt(received);
}
if (displayed != null) {
m['displayed'] = boolToInt(displayed);
}
if (acked != null) {
m['acked'] = boolToInt(acked);
}
if (errorType != notSpecified) {
m['errorType'] = errorType as int?;
}
if (warningType != notSpecified) {
m['warningType'] = warningType as int?;
}
if (isFileUploadNotification != null) {
m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
}
if (isDownloading != null) {
m['isDownloading'] = boolToInt(isDownloading);
}
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(() {
@@ -256,7 +533,6 @@ class MessageService {
/// 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
/// - 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
///
/// [conversationJid] is the bare JID of the conversation this message belongs to.
@@ -271,7 +547,7 @@ class MessageService {
String bareSender,
bool selfRetract,
) async {
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId(
final msg = await getMessageByXmppId(
originId,
conversationJid,
);
@@ -294,24 +570,13 @@ class MessageService {
}
final isMedia = msg.isMedia;
final mediaUrl = msg.mediaUrl;
final retractedMessage = await updateMessage(
msg.id,
isMedia: false,
mediaUrl: null,
mediaType: null,
warningType: null,
errorType: null,
srcUrl: null,
key: null,
iv: null,
encryptionScheme: null,
mediaWidth: null,
mediaHeight: null,
mediaSize: null,
isRetracted: true,
thumbnailData: null,
body: '',
fileMetadata: null,
);
sendEvent(MessageUpdatedEvent(message: retractedMessage));
@@ -319,40 +584,23 @@ class MessageService {
final conversation = await cs.getConversationByJid(conversationJid);
if (conversation != null) {
if (conversation.lastMessage?.id == msg.id) {
var newConversation = conversation.copyWith(
final newConversation = conversation.copyWith(
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);
sendEvent(
ConversationUpdatedEvent(
conversation: newConversation,
),
);
if (isMedia) {
// Remove the file
await GetIt.I.get<FilesService>().removeFileIfNotReferenced(
msg.fileMetadata!,
);
}
}
} else {
_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) {
body = t.messages.sticker;
} else if (m.isMedia) {
body = mimeTypeToEmoji(m.mediaType);
body = mimeTypeToEmoji(m.fileMetadata!.mimeType);
} else {
body = m.body;
}
@@ -126,7 +126,7 @@ class NotificationsService {
? NotificationLayout.BigPicture
: NotificationLayout.Messaging,
category: NotificationCategory.Message,
bigPicture: m.isThumbnailable ? 'file://${m.mediaUrl}' : null,
bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
payload: <String, String>{
'conversationJid': c.jid,
'sid': m.sid,

View File

@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import 'package:hex/hex.dart';
import 'package:logging/logging.dart';
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/helpers.dart';
import 'package:moxxyv2/service/message.dart';
import 'package:moxxyv2/service/moxxmpp/omemo.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/omemo_device.dart' as model;
import 'package:omemo_dart/omemo_dart.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.dart';
class OmemoDoubleRatchetWrapper {
@@ -40,21 +44,19 @@ class OmemoService {
final done = await _lock.synchronized(() => _initialized);
if (done) return;
final db = GetIt.I.get<DatabaseService>();
final device = await db.loadOmemoDevice(jid);
final device = await _loadOmemoDevice(jid);
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
final deviceList = <String, List<int>>{};
if (device == null) {
_log.info('No OMEMO marker found. Generating OMEMO identity...');
} else {
_log.info('OMEMO marker found. Restoring OMEMO state...');
for (final ratchet
in await GetIt.I.get<DatabaseService>().loadRatchets()) {
for (final ratchet in await _loadRatchets()) {
final key = RatchetMapKey(ratchet.jid, ratchet.id);
ratchetMap[key] = ratchet.ratchet;
}
deviceList.addAll(await db.loadOmemoDeviceList());
deviceList.addAll(await _loadOmemoDeviceList());
}
final om = GetIt.I
@@ -82,7 +84,7 @@ class OmemoService {
omemoManager.eventStream.listen((event) async {
if (event is RatchetModifiedEvent) {
await GetIt.I.get<DatabaseService>().saveRatchet(
await _saveRatchet(
OmemoDoubleRatchetWrapper(
event.ratchet,
event.deviceId,
@@ -93,7 +95,7 @@ class OmemoService {
if (event.added) {
// Cache the fingerprint
final fingerprint = await event.ratchet.getOmemoFingerprint();
await GetIt.I.get<DatabaseService>().addFingerprintsToCache([
await _addFingerprintsToCache([
OmemoCacheTriple(
event.jid,
event.deviceId,
@@ -143,7 +145,6 @@ class OmemoService {
DateTime.now().millisecondsSinceEpoch,
'',
jid,
false,
'',
false,
false,
@@ -171,7 +172,7 @@ class OmemoService {
final oldId = await omemoManager.getDeviceId();
// Clear the database
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
await _emptyOmemoSessionTables();
// Regenerate the identity in the background
final device = await compute(generateNewIdentityImpl, jid);
@@ -228,11 +229,11 @@ class OmemoService {
}
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 {
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
@@ -250,7 +251,7 @@ class OmemoService {
final device = await omemoManager.getDevice();
final bundlesRaw = await dm.discoItemsQuery(
bareJid.toString(),
bareJid,
node: moxxmpp.omemoBundlesXmlns,
);
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
@@ -305,7 +306,7 @@ class OmemoService {
_fingerprintCache[bareJid] = map;
// 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();
if (!_fingerprintCache.containsKey(bareJid)) {
// First try to load it from the database
final triples = await GetIt.I
.get<DatabaseService>()
.getFingerprintsFromCache(bareJid);
final triples = await _getFingerprintsFromCache(bareJid);
if (triples.isEmpty) {
// We found no fingerprints in the database, so try to fetch them
await _fetchFingerprintsAndCache(jid);
@@ -361,23 +360,22 @@ class OmemoService {
}
Future<void> commitTrustManager(Map<String, dynamic> json) async {
await GetIt.I.get<DatabaseService>().saveTrustCache(
await _saveTrustCache(
json['trust']! as Map<String, int>,
);
await GetIt.I.get<DatabaseService>().saveTrustEnablementList(
await _saveTrustEnablementList(
json['enable']! as Map<String, bool>,
);
await GetIt.I.get<DatabaseService>().saveTrustDeviceList(
await _saveTrustDeviceList(
json['devices']! as Map<String, List<int>>,
);
}
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
final db = GetIt.I.get<DatabaseService>();
return MoxxyBTBVTrustManager(
await db.loadTrustCache(),
await db.loadTrustEnablementList(),
await db.loadTrustDeviceList(),
await _loadTrustCache(),
await _loadTrustEnablementList(),
await _loadTrustDeviceList(),
);
}
@@ -455,4 +453,301 @@ class OmemoService {
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:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/service/database/helpers.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
class PreferencesService {
PreferencesState? _preferences;
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 {
@@ -21,6 +46,38 @@ class PreferencesService {
if (_preferences == null) await _loadPreferences();
_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:moxxmpp/moxxmpp.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/helpers.dart';
import 'package:moxxyv2/service/not_specified.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/subscription.dart';
@@ -42,7 +44,9 @@ class RosterService {
String? contactDisplayName, {
List<String> groups = const [],
}) async {
final item = await GetIt.I.get<DatabaseService>().addRosterItemFromData(
// TODO(PapaTutuWawa): Handle groups
final i = RosterItem(
-1,
avatarUrl,
avatarHash,
jid,
@@ -50,10 +54,17 @@ class RosterService {
subscription,
ask,
pseudoRosterItem,
contactId,
contactAvatarPath,
contactDisplayName,
groups: groups,
<String>[],
contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
);
final item = i.copyWith(
id: await GetIt.I
.get<DatabaseService>()
.database
.insert(rosterTable, i.toDatabaseJson()),
);
// Update the cache
@@ -76,19 +87,49 @@ class RosterService {
Object? contactAvatarPath = notSpecified,
Object? contactDisplayName = notSpecified,
}) async {
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
id,
avatarUrl: avatarUrl,
avatarHash: avatarHash,
title: title,
subscription: subscription,
ask: ask,
pseudoRosterItem: pseudoRosterItem,
groups: groups,
contactId: contactId,
contactAvatarPath: contactAvatarPath,
contactDisplayName: contactDisplayName,
final i = <String, dynamic>{};
if (avatarUrl != null) {
i['avatarUrl'] = avatarUrl;
}
if (avatarHash != null) {
i['avatarHash'] = avatarHash;
}
if (title != null) {
i['title'] = title;
}
/*
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
_rosterCache![newItem.jid] = newItem;
@@ -96,10 +137,14 @@ class RosterService {
return newItem;
}
/// Wrapper around [DatabaseService]'s removeRosterItem.
/// Removes a roster item from the database and cache
Future<void> removeRosterItem(int id) async {
// 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');
/// Update cache
@@ -136,14 +181,16 @@ class RosterService {
/// Load the roster from the database. This function is guarded against loading the
/// roster multiple times and thus creating too many "RosterDiff" actions.
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>{};
for (final item in items) {
_rosterCache![item.jid] = item;
}
return items;
return items.toList();
}
/// 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.getContactDisplayName(contactId),
);
final result = await GetIt.I
.get<XmppConnection>()
.getRosterManager()!

View File

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

View File

@@ -1,20 +1,24 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:ui';
import 'package:archive/archive.dart';
import 'package:get_it/get_it.dart';
import 'package:logging/logging.dart';
import 'package:moxlib/moxlib.dart';
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/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/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/xmpp_state.dart';
import 'package:moxxyv2/shared/events.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_pack.dart';
import 'package:path/path.dart' as p;
@@ -26,31 +30,69 @@ class StickersService {
Future<StickerPack?> getStickerPackById(String id) async {
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
final pack = await GetIt.I.get<DatabaseService>().getStickerPackById(id);
if (pack == null) return null;
_stickerPacks[id] = pack;
return _stickerPacks[id];
}
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,
final db = GetIt.I.get<DatabaseService>().database;
final rawPack = await db.query(
stickerPacksTable,
where: 'id = ?',
whereArgs: [id],
limit: 1,
);
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 {
if (_stickerPacks.isEmpty) {
final packs = await GetIt.I.get<DatabaseService>().loadStickerPacks();
for (final pack in packs) {
_stickerPacks[pack.id] = pack;
final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
stickerPacksTable,
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();
}
@@ -59,21 +101,27 @@ class StickersService {
assert(pack != null, 'The sticker pack must exist');
// Delete the files
final stickerPackPath = await getStickerPackPath(
pack!.hashAlgorithm,
pack.hashValue,
);
final stickerPackDir = Directory(stickerPackPath);
if (stickerPackDir.existsSync()) {
unawaited(
stickerPackDir.delete(
recursive: true,
),
for (final sticker in pack!.stickers) {
if (sticker.fileMetadata.path == null) {
continue;
}
await GetIt.I.get<FilesService>().updateFileMetadata(
sticker.fileMetadata.id,
path: null,
);
final file = File(sticker.fileMetadata.path!);
if (file.existsSync()) {
await file.delete();
}
}
// 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
_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(
moxxmpp.JID jid,
String stickerPackId,
@@ -158,24 +196,78 @@ class StickersService {
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 {
assert(!remotePack.local, 'Sticker pack must be remote');
final stickerPackPath = await _getStickerPackPath(
remotePack.hashAlgorithm,
remotePack.hashValue,
);
var success = true;
final stickers = List<Sticker>.from(remotePack.stickers);
for (var i = 0; i < stickers.length; i++) {
final sticker = stickers[i];
final stickerPath = p.join(
stickerPackPath,
sticker.hashes.values.first,
final stickerPath = await computeCachedPathForFile(
sticker.fileMetadata.filename,
sticker.fileMetadata.plaintextHashes,
);
// Get file metadata
final fileMetadataRaw =
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
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.urlSources.first),
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
stickerPath,
(_, __) {},
);
@@ -185,9 +277,15 @@ class StickersService {
success = false;
break;
}
stickers[i] = sticker.copyWith(
path: stickerPath,
hashKey: getStickerHashKey(sticker.hashes),
}
stickers[i] = await _addStickerFromData(
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
final db = GetIt.I.get<DatabaseService>();
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,
),
);
}
await _addStickerPackFromData(remotePack);
// Publish but don't block
unawaited(
@@ -225,7 +303,7 @@ class StickersService {
);
return remotePack.copyWith(
stickers: stickersDb,
stickers: stickers,
local: true,
);
}
@@ -299,8 +377,6 @@ class StickersService {
final stickerDir = Directory(stickerDirPath);
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
final db = GetIt.I.get<DatabaseService>();
// Create the sticker pack first
final stickerPack = StickerPack(
pack.hashValue,
@@ -312,33 +388,65 @@ class StickersService {
pack.restricted,
true,
);
await db.addStickerPackFromData(stickerPack);
await _addStickerPackFromData(stickerPack);
// Add all stickers
final stickers = List<Sticker>.empty(growable: true);
for (final sticker in pack.stickers) {
final filename = sticker.metadata.name!;
final stickerFile = archive.findFile(filename)!;
final stickerPath = p.join(stickerDirPath, filename);
await File(stickerPath).writeAsBytes(
stickerFile.content as List<int>,
// Get the "path" to the sticker
final stickerPath = await computeCachedPathForFile(
sticker.metadata.name!,
sticker.metadata.hashes,
);
stickers.add(
await db.addStickerFromData(
sticker.metadata.mediaType!,
sticker.metadata.desc!,
sticker.metadata.size!,
// 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,
sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((moxxmpp.StatelessFileSharingUrlSource source) => source.url)
.toList(),
stickerPath,
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(
await _addStickerFromData(
getStrongestHashFromMap(sticker.metadata.hashes) ??
DateTime.now().millisecondsSinceEpoch.toString(),
pack.hashValue,
sticker.metadata.desc!,
sticker.suggests,
fileMetadataRaw.fileMetadata,
),
);
}

View File

@@ -1,6 +1,8 @@
import 'package:get_it/get_it.dart';
import 'package:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
import 'package:synchronized/synchronized.dart';
class SubscriptionRequestService {
@@ -8,15 +10,18 @@ class SubscriptionRequestService {
final Lock _lock = Lock();
DatabaseService get _db => GetIt.I.get<DatabaseService>();
/// Only load data from the database into
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
/// been loaded.
Future<void> _loadSubscriptionRequestsIfNeeded() async {
await _lock.synchronized(() async {
_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)) {
_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 {
if (_subscriptionRequests!.contains(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/contacts.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/httpfiletransfer/helpers.dart';
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.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/not_specified.dart';
import 'package:moxxyv2/service/notifications.dart';
import 'package:moxxyv2/service/omemo/omemo.dart';
import 'package:moxxyv2/service/preferences.dart';
import 'package:moxxyv2/service/reactions.dart';
import 'package:moxxyv2/service/roster.dart';
import 'package:moxxyv2/service/service.dart';
import 'package:moxxyv2/service/stickers.dart';
import 'package:moxxyv2/service/subscription.dart';
import 'package:moxxyv2/service/xmpp_state.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/helpers.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/reaction.dart';
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
import 'package:omemo_dart/omemo_dart.dart';
import 'package:path/path.dart' as pathlib;
@@ -47,6 +47,7 @@ class XmppService {
XmppService() {
_eventHandler.addMatchers([
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
EventTypeMatcher<StreamNegotiationsDoneEvent>(_onStreamNegotiationsDone),
EventTypeMatcher<ResourceBoundEvent>(_onResourceBound),
EventTypeMatcher<SubscriptionRequestReceivedEvent>(
_onSubscriptionRequestReceived,
@@ -222,7 +223,6 @@ class XmppService {
timestamp,
conn.connectionSettings.jid.toString(),
recipient,
sticker != null,
sid,
false,
c.type == ConversationType.note ? true : c.encrypted,
@@ -231,9 +231,7 @@ class XmppService {
originId: originId,
quoteId: quotedMessage?.sid,
stickerPackId: sticker?.stickerPackId,
stickerHashKey: sticker?.hashKey,
srcUrl: sticker?.urlSources.first,
mediaType: sticker?.mediaType,
fileMetadata: sticker?.fileMetadata,
received: c.type == ConversationType.note ? true : false,
displayed: c.type == ConversationType.note ? true : false,
);
@@ -260,6 +258,7 @@ class XmppService {
}
if (conversation?.type == ConversationType.chat) {
final moxxmppSticker = sticker?.toMoxxmpp();
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
MessageDetails(
to: recipient,
@@ -273,23 +272,12 @@ class XmppService {
chatState: chatState,
shouldEncrypt: conversation!.encrypted,
stickerPackId: sticker?.stickerPackId,
sfs: sticker == null
? null
: StatelessFileSharingData(
FileMetadataData(
mediaType: sticker.mediaType,
width: sticker.width,
height: sticker.height,
desc: sticker.desc,
size: sticker.size,
thumbnails: [],
hashes: sticker.hashes,
),
sticker.urlSources
// ignore: unnecessary_lambdas
.map((s) => StatelessFileSharingUrlSource(s))
.toList(),
),
sfs: moxxmppSticker != null
? StatelessFileSharingData(
moxxmppSticker.metadata,
moxxmppSticker.sources,
)
: null,
setOOBFallbackBody: sticker != null ? false : true,
),
);
@@ -301,50 +289,67 @@ class XmppService {
}
}
MediaFileLocation? _getMessageSrcUrl(MessageEvent event) {
if (event.sfs != null) {
final source = firstWhereOrNull(
event.sfs!.sources,
(StatelessFileSharingSource source) {
return source is StatelessFileSharingUrlSource ||
source is StatelessFileSharingEncryptedSource;
},
);
MediaFileLocation? _getEmbeddedFile(MessageEvent event) {
if (event.sfs?.sources.isNotEmpty ?? false) {
// final source = firstWhereOrNull(
// event.sfs!.sources,
// (StatelessFileSharingSource source) {
// return source is StatelessFileSharingUrlSource ||
// source is StatelessFileSharingEncryptedSource;
// },
// );
final name = event.sfs?.metadata.name;
if (source is StatelessFileSharingUrlSource) {
final hasUrlSource = firstWhereOrNull(
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(
source.url,
name != null ? escapeFilename(name) : filenameFromUrl(source.url),
sources,
name != null ? escapeFilename(name) : filenameFromUrl(sources.first),
null,
null,
null,
event.sfs?.metadata.hashes,
event.sfs!.metadata.hashes,
null,
event.sfs!.metadata.size,
);
} else {
final esource = source! as StatelessFileSharingEncryptedSource;
final encryptedSource = firstWhereOrNull(
event.sfs!.sources,
(src) => src is StatelessFileSharingEncryptedSource,
)! as StatelessFileSharingEncryptedSource;
return MediaFileLocation(
esource.source.url,
[encryptedSource.source.url],
name != null
? escapeFilename(name)
: filenameFromUrl(esource.source.url),
esource.encryption.toNamespace(),
esource.key,
esource.iv,
: filenameFromUrl(encryptedSource.source.url),
encryptedSource.encryption.toNamespace(),
encryptedSource.key,
encryptedSource.iv,
event.sfs?.metadata.hashes,
esource.hashes,
encryptedSource.hashes,
event.sfs!.metadata.size,
);
}
} else if (event.oob != null) {
return MediaFileLocation(
event.oob!.url!,
[event.oob!.url!],
filenameFromUrl(event.oob!.url!),
null,
null,
null,
null,
null,
null,
);
}
@@ -355,38 +360,44 @@ class XmppService {
final result = await GetIt.I
.get<XmppConnection>()
.getDiscoManager()!
.discoInfoQuery(event.fromJid.toString());
.discoInfoQuery(event.fromJid);
if (result.isType<DiscoError>()) return;
final info = result.get<DiscoInfo>();
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
unawaited(
GetIt.I.get<XmppConnection>().sendStanza(
StanzaDetails(
Stanza.message(
to: event.fromJid.toBare().toString(),
type: event.type,
children: [
makeChatMarker(
'received',
event.stanzaId.originId ?? event.sid,
event.originId ?? event.sid,
)
],
),
awaitable: false,
),
),
);
} else if (event.deliveryReceiptRequested &&
info.features.contains(deliveryXmlns)) {
unawaited(
GetIt.I.get<XmppConnection>().sendStanza(
StanzaDetails(
Stanza.message(
to: event.fromJid.toBare().toString(),
type: event.type,
children: [
makeMessageDeliveryResponse(
event.stanzaId.originId ?? event.sid,
event.originId ?? event.sid,
)
],
),
awaitable: false,
),
),
);
}
@@ -401,6 +412,7 @@ class XmppService {
unawaited(
GetIt.I.get<XmppConnection>().sendStanza(
StanzaDetails(
Stanza.message(
to: to,
type: 'chat',
@@ -408,6 +420,8 @@ class XmppService {
makeChatMarker('displayed', sid),
],
),
awaitable: false,
),
),
);
}
@@ -442,7 +456,10 @@ class XmppService {
_loginTriggeredFromUI = triggeredFromUI;
conn
..connectionSettings = settings
..getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.resource = lastResource;
..getNegotiatorById<StreamManagementNegotiator>(
streamManagementNegotiator,
)!
.resource = lastResource;
unawaited(
conn.connect(
waitForConnection: true,
@@ -463,7 +480,10 @@ class XmppService {
_loginTriggeredFromUI = triggeredFromUI;
conn
..connectionSettings = settings
..getNegotiatorById<StreamManagementNegotiator>(streamManagementNegotiator)!.resource = lastResource;
..getNegotiatorById<StreamManagementNegotiator>(
streamManagementNegotiator,
)!
.resource = lastResource;
installEventHandlers();
return conn.connect(
waitForConnection: true,
@@ -471,34 +491,6 @@ class XmppService {
);
}
/// 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 {
// Create a new message
final ms = GetIt.I.get<MessageService>();
@@ -516,17 +508,13 @@ class XmppService {
final encrypt = <String, bool>{};
// Recipient -> Last message Id
final lastMessages = <String, Message>{};
// Path -> Metadata Id
final metadataMap = <String, String>{};
// Create the messages and shared media entries
final conn = GetIt.I.get<XmppConnection>();
for (final path in paths) {
final pathMime = lookupMimeType(path);
for (final recipient in recipients) {
final conversation = await cs.getConversationByJid(recipient);
encrypt[recipient] =
conversation?.encrypted ?? prefs.enableOmemoByDefault;
// TODO(Unknown): Do the same for videos
if (pathMime != null && pathMime.startsWith('image/')) {
final imageSize = await getImageSizeFromPath(path);
@@ -540,25 +528,47 @@ class XmppService {
}
}
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) {
final conversation = await cs.getConversationByJid(recipient);
encrypt[recipient] =
conversation?.encrypted ?? prefs.enableOmemoByDefault;
final msg = await ms.addMessageFromData(
'',
DateTime.now().millisecondsSinceEpoch,
conn.connectionSettings.jid.toString(),
recipient,
true,
conn.generateId(),
false,
conversation?.type == ConversationType.note
? true
: encrypt[recipient]!,
// TODO(Unknown): Maybe make this depend on some setting
false,
mediaUrl: path,
mediaType: pathMime,
false,
fileMetadata: metadata,
originId: conn.generateId(),
mediaWidth: dimensions[path]?.width.toInt(),
mediaHeight: dimensions[path]?.height.toInt(),
filename: pathlib.basename(path),
isUploading:
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>();
for (final recipient in recipients) {
await cs.createOrUpdateConversation(
recipient,
@@ -590,7 +596,7 @@ class XmppService {
// Create
final rosterItem = await rs.getRosterItemByJid(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?
rosterItem?.title ?? recipient.split('@').first,
lastMessages[recipient],
@@ -602,22 +608,11 @@ class XmppService {
true,
prefs.defaultMuteState,
prefs.enableOmemoByDefault,
paths.length,
contactId,
await css.getProfilePicturePathForJid(recipient),
await css.getContactDisplayName(contactId),
);
final sharedMedia = await _createSharedMedia(
messages,
paths,
recipient,
newConversation.jid,
);
newConversation = newConversation.copyWith(
sharedMedia: sharedMedia.sublist(0, 8),
);
// Update the cache
cs.setConversation(newConversation);
@@ -628,28 +623,11 @@ class XmppService {
},
update: (c) async {
// Update
var newConversation = await cs.updateConversation(
final newConversation = await cs.updateConversation(
c.jid,
lastMessage: lastMessages[recipient],
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
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
@@ -710,6 +688,7 @@ class XmppService {
pathMime,
encrypt,
messages[path]!,
metadataMap[path]!,
thumbnails[path] ?? [],
),
);
@@ -763,18 +742,10 @@ class XmppService {
}
}
Future<void> _onConnectionStateChanged(
ConnectionStateChangedEvent event, {
Future<void> _onStreamNegotiationsDone(
StreamNegotiationsDoneEvent event, {
dynamic extra,
}) async {
setNotificationText(event.state);
await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
event.before,
event.state,
);
if (event.state == XmppConnectionState.connected) {
final connection = GetIt.I.get<XmppConnection>();
// TODO(Unknown): Maybe have something better
@@ -838,6 +809,17 @@ class XmppService {
);
}
}
Future<void> _onConnectionStateChanged(
ConnectionStateChangedEvent event, {
dynamic extra,
}) async {
setNotificationText(event.state);
await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
event.before,
event.state,
);
}
Future<void> _onResourceBound(
@@ -876,11 +858,10 @@ class XmppService {
dynamic extra,
}) async {
_log.finest('Received delivery receipt from ${event.from}');
final db = GetIt.I.get<DatabaseService>();
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
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) {
_log.warning(
'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 {
_log.finest('Chat marker from ${event.from}');
final db = GetIt.I.get<DatabaseService>();
final ms = GetIt.I.get<MessageService>();
final cs = GetIt.I.get<ConversationService>();
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) {
_log.warning('Did not find the message in the database!');
return;
@@ -1010,9 +990,8 @@ class XmppService {
// 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.
return embeddedFile != null &&
Uri.parse(embeddedFile.url).scheme == 'https' &&
implies(event.oob != null, event.body == event.oob?.url) &&
event.stickerPackId == null;
Uri.parse(embeddedFile.urls.first).scheme == 'https' &&
implies(event.oob != null, event.body == event.oob?.url);
}
/// Handle a message retraction given the MessageEvent [event].
@@ -1141,9 +1120,10 @@ class XmppService {
) async {
final ms = GetIt.I.get<MessageService>();
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
final msg = await ms.getMessageByStanzaOrOriginId(
conversationJid,
final msg = await ms.getMessageByXmppId(
event.messageReactions!.messageId,
conversationJid,
queryReactionPreview: false,
);
if (msg == null) {
_log.warning(
@@ -1152,80 +1132,10 @@ class XmppService {
return;
}
final state = await GetIt.I.get<XmppStateService>().getXmppState();
final sender = event.fromJid.toBare().toString();
final isCarbon = sender == state.jid;
final reactions = List<Reaction>.from(msg.reactions);
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),
await GetIt.I.get<ReactionsService>().processNewReactions(
msg,
event.fromJid.toBare().toString(),
event.messageReactions!.emojis,
);
}
@@ -1313,84 +1223,64 @@ class XmppService {
}
// 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
// that the message body and the OOB url are the same if the OOB url is not null.
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
final shouldDownload = await _shouldDownloadFile(conversationJid);
// The thumbnail for the embedded file.
final thumbnailData = _getThumbnailData(event);
final shouldDownload =
isFileEmbedded && await _shouldDownloadFile(conversationJid);
// 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
// notification will be created later by the [DownloadService]. If we don't want the
// 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.
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
// - a sticker was received,
// - the sender is in the roster,
// - we don't have the sticker pack locally,
// - and it is enabled in the settings
if (event.stickerPackId != null &&
stickerPack == null &&
prefs.autoDownloadStickersFromContacts &&
isInRoster) {
unawaited(
GetIt.I.get<StickersService>().importFromPubSubWithEvent(
event.fromJid,
event.stickerPackId!,
),
FileMetadataWrapper? fileMetadata;
if (isFileEmbedded) {
final thumbnail = _getThumbnailData(event);
fileMetadata =
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
embeddedFile!,
mimeGuess,
embeddedFile.size,
dimensions,
// TODO(Unknown): Maybe we switch to something else?
thumbnail != null ? 'blurhash' : null,
thumbnail,
createHashPointers: false,
);
}
// Create the message in the database
final ms = GetIt.I.get<MessageService>();
final dimensions = _getDimensions(event);
var message = await ms.addMessageFromData(
messageBody,
messageTimestamp,
event.fromJid.toString(),
conversationJid,
isFileEmbedded || event.fun != null || event.stickerPackId != null,
event.sid,
event.fun != null,
event.encrypted,
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ??
false,
srcUrl: embeddedFile?.url,
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(),
fileMetadata: fileMetadata?.fileMetadata,
quoteId: replyId,
originId: event.stanzaId.originId,
originId: event.originId,
errorType: errorTypeFromException(event.other['encryption_error']),
plaintextHashes: event.sfs?.metadata.hashes,
stickerPackId: event.stickerPackId,
stickerHashKey: stickerHashKey,
);
// Attempt to auto-download the embedded file
if (isFileEmbedded && shouldDownload) {
// Attempt to auto-download the embedded file, if
// - 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 metadata = await peekFile(embeddedFile!.url);
final metadata = await peekFile(embeddedFile!.urls.first);
_log.finest('Advertised file MIME: ${metadata.mime}');
if (metadata.mime != null) mimeGuess = metadata.mime;
@@ -1408,6 +1298,7 @@ class XmppService {
FileDownloadJob(
embeddedFile,
message.id,
message.fileMetadata!.id,
conversationJid,
mimeGuess,
),
@@ -1416,6 +1307,10 @@ class XmppService {
// Make sure we create the notification
shouldNotify = true;
}
} else {
if (fileMetadata?.retrieved ?? false) {
_log.info('Not downloading file as we already have it locally');
}
}
final cs = GetIt.I.get<ConversationService>();
@@ -1448,9 +1343,6 @@ class XmppService {
true,
prefs.defaultMuteState,
message.encrypted,
// Always use 0 here, since a possible shared media item only is created
// afterwards.
0,
contactId,
await css.getProfilePicturePathForJid(conversationJid),
await css.getContactDisplayName(contactId),
@@ -1544,23 +1436,35 @@ class XmppService {
}
// 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?
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
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.id,
srcUrl: embeddedFile!.url,
key: embeddedFile.keyBase64,
iv: embeddedFile.ivBase64,
fileMetadata: fileMetadata ?? notSpecified,
isFileUploadNotification: false,
isDownloading: shouldDownload,
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
sendEvent(MessageUpdatedEvent(message: message));
@@ -1570,11 +1474,16 @@ class XmppService {
FileDownloadJob(
embeddedFile,
message.id,
oldFileMetadata!.id,
conversationJid,
_getMimeGuess(event),
shouldShowNotification: false,
),
);
} else {
if (fileMetadata != null) {
_log.info('Not downloading file as we already have it locally');
}
}
} else {
_log.warning(

View File

@@ -1,6 +1,8 @@
import 'package:get_it/get_it.dart';
import 'package:moxxyv2/service/database/constants.dart';
import 'package:moxxyv2/service/database/database.dart';
import 'package:moxxyv2/shared/models/xmpp_state.dart';
import 'package:sqflite_sqlcipher/sqflite.dart';
class XmppStateService {
/// Persistent state around the connection, like the SM token, etc.
@@ -9,13 +11,29 @@ class XmppStateService {
Future<XmppState> getXmppState() async {
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!;
}
/// A wrapper to modify the [XmppState] and commit it.
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
_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:moxxyv2/shared/models/message.dart';
import 'package:moxxyv2/shared/models/preferences.dart';
import 'package:moxxyv2/shared/models/sticker.dart';
import 'package:moxxyv2/shared/models/sticker_pack.dart';
part 'commands.moxxy.dart';

View File

@@ -1,10 +1,10 @@
import 'package:moxlib/awaitabledatasender.dart';
import 'package:moxplatform/moxplatform.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/omemo_device.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/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:moxxmpp/moxxmpp.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/ui/bloc/preferences_bloc.dart';
@@ -58,7 +57,6 @@ class Conversation with _$Conversation {
ConversationType type,
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
int lastChangeTimestamp,
List<SharedMedium> sharedMedia,
// Indicates if the conversation should be shown on the homescreen
bool open,
// 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)
bool encrypted,
// The current chat state
@ConversationChatStateConverter() ChatState chatState,
// The amount of shared media items that are in the database
int sharedMediaAmount, {
@ConversationChatStateConverter() ChatState chatState, {
// The id of the contact in the device's phonebook if it exists
String? contactId,
// The path to the contact avatar, if available
@@ -91,12 +87,10 @@ class Conversation with _$Conversation {
Map<String, dynamic> json,
bool inRoster,
String subscription,
List<SharedMedium> sharedMedia,
Message? lastMessage,
) {
return Conversation.fromJson({
...json,
'sharedMedia': <Map<String, dynamic>>[],
'muted': intToBool(json['muted']! as int),
'open': intToBool(json['open']! as int),
'inRoster': inRoster,
@@ -106,7 +100,6 @@ class Conversation with _$Conversation {
const ConversationChatStateConverter().toJson(ChatState.gone),
}).copyWith(
lastMessage: lastMessage,
sharedMedia: sharedMedia,
);
}
@@ -114,7 +107,6 @@ class Conversation with _$Conversation {
final map = toJson()
..remove('id')
..remove('chatState')
..remove('sharedMedia')
..remove('inRoster')
..remove('subscription')
..remove('lastMessage');
@@ -152,6 +144,9 @@ class Conversation with _$Conversation {
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.

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/shared/error_types.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';
part 'message.freezed.dart';
@@ -11,24 +11,12 @@ part 'message.g.dart';
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) {
if (data == null) return <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) {
if (data == null) return null;
if (data.isEmpty) return null;
@@ -46,26 +34,15 @@ class Message with _$Message {
// The database-internal identifier of the message
int id,
String conversationJid,
// True if the message contains some embedded media
bool isMedia,
bool isFileUploadNotification,
bool encrypted,
// True if the message contains a <no-store> Message Processing Hint. False if not
bool containsNoStore, {
int? errorType,
int? warningType,
String? mediaUrl,
FileMetadata? fileMetadata,
@Default(false) bool isDownloading,
@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 displayed,
@Default(false) bool acked,
@@ -73,13 +50,8 @@ class Message with _$Message {
@Default(false) bool isEdited,
String? originId,
Message? quotes,
String? filename,
Map<String, String>? plaintextHashes,
Map<String, String>? ciphertextHashes,
int? mediaSize,
@Default([]) List<Reaction> reactions,
@Default([]) List<String> reactionsPreview,
String? stickerPackId,
String? stickerHashKey,
int? pseudoMessageType,
Map<String, dynamic>? pseudoMessageData,
}) = _Message;
@@ -90,34 +62,31 @@ class Message with _$Message {
factory Message.fromJson(Map<String, dynamic> 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({
...json,
'received': intToBool(json['received']! as int),
'displayed': intToBool(json['displayed']! as int),
'acked': intToBool(json['acked']! as int),
'isMedia': intToBool(json['isMedia']! as int),
'isFileUploadNotification':
intToBool(json['isFileUploadNotification']! 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),
'isUploading': intToBool(json['isUploading']! as int),
'isRetracted': intToBool(json['isRetracted']! as int),
'isEdited': intToBool(json['isEdited']! as int),
'containsNoStore': intToBool(json['containsNoStore']! as int),
'reactions': <Map<String, dynamic>>[],
'reactionsPreview': reactionsPreview,
'pseudoMessageData':
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
}).copyWith(
quotes: quotes,
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>)
.cast<Map<String, dynamic>>()
.map<Reaction>(Reaction.fromJson)
.toList(),
fileMetadata: fileMetadata,
);
}
@@ -125,29 +94,25 @@ class Message with _$Message {
final map = toJson()
..remove('id')
..remove('quotes')
..remove('reactions')
..remove('reactionsPreview')
..remove('fileMetadata')
..remove('pseudoMessageData');
return {
...map,
'isMedia': boolToInt(isMedia),
'isFileUploadNotification': boolToInt(isFileUploadNotification),
'received': boolToInt(received),
'displayed': boolToInt(displayed),
'acked': boolToInt(acked),
'encrypted': boolToInt(encrypted),
'file_metadata_id': fileMetadata?.id,
// NOTE: Message.quote_id is a foreign-key
'quote_id': quotes?.id,
'plaintextHashes': _optionalJsonEncode(plaintextHashes),
'ciphertextHashes': _optionalJsonEncode(ciphertextHashes),
'isDownloading': boolToInt(isDownloading),
'isUploading': boolToInt(isUploading),
'isRetracted': boolToInt(isRetracted),
'isEdited': boolToInt(isEdited),
'containsNoStore': boolToInt(containsNoStore),
'reactions': jsonEncode(
reactions.map((r) => r.toJson()).toList(),
),
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
};
}
@@ -161,7 +126,7 @@ class Message with _$Message {
/// Returns a representative emoji for a message. Its primary purpose is
/// to provide a universal fallback for quoted media messages.
String get messageEmoji {
return mimeTypeToEmoji(mediaType, addTypeName: false);
return mimeTypeToEmoji(fileMetadata?.mimeType, addTypeName: false);
}
/// 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
/// images.
bool get isThumbnailable =>
!isPseudoMessage &&
isMedia &&
mediaType != null &&
(mediaType!.startsWith('image/') || mediaType!.startsWith('video/'));
bool get isThumbnailable {
if (isPseudoMessage || !isMedia || fileMetadata?.mimeType == null) {
return false;
}
final mimeType = fileMetadata!.mimeType!;
return mimeType.startsWith('image/') || mimeType.startsWith('video/');
}
/// Returns true if the message can be copied to the clipboard.
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
/// Returns true if the message is a sticker
bool get isSticker =>
isMedia &&
stickerPackId != null &&
stickerHashKey != null &&
!isPseudoMessage;
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
/// True if the message is a media message
bool get isMedia => fileMetadata != null;
}

View File

@@ -6,22 +6,14 @@ part 'reaction.g.dart';
@freezed
class Reaction with _$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,
// 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;
const Reaction._();
/// JSON
factory Reaction.fromJson(Map<String, dynamic> 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 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
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.g.dart';
@@ -9,84 +12,89 @@ part 'sticker.g.dart';
@freezed
class Sticker with _$Sticker {
factory Sticker(
String hashKey,
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 id,
String stickerPackId,
String desc,
Map<String, String> suggests,
FileMetadata fileMetadata,
) = _Sticker;
const Sticker._();
/// Moxxmpp
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) =>
Sticker(
getStickerHashKey(sticker.metadata.hashes),
sticker.metadata.mediaType!,
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) {
final hashKey = getStickerHashKey(sticker.metadata.hashes);
final firstUrl = (sticker.sources.firstWhereOrNull(
(src) => src is moxxmpp.StatelessFileSharingUrlSource,
)! as moxxmpp.StatelessFileSharingUrlSource)
.url;
return Sticker(
hashKey,
stickerPackId,
sticker.metadata.desc!,
sticker.metadata.size!,
sticker.metadata.width,
sticker.metadata.height,
sticker.metadata.hashes,
sticker.suggests,
FileMetadata(
hashKey,
null,
sticker.sources
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
.map((src) => src.url)
.toList(),
'',
stickerPackId,
sticker.suggests,
sticker.metadata.mediaType,
sticker.metadata.size,
null,
null,
sticker.metadata.width,
sticker.metadata.height,
sticker.metadata.hashes,
null,
null,
null,
null,
sticker.metadata.name ?? path.basename(firstUrl),
),
);
}
/// JSON
factory Sticker.fromJson(Map<String, dynamic> json) =>
_$StickerFromJson(json);
factory Sticker.fromDatabaseJson(Map<String, dynamic> json) {
factory Sticker.fromDatabaseJson(
Map<String, dynamic> json,
FileMetadata fileMetadata,
) {
return Sticker.fromJson({
...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':
(jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>)
.cast<String, String>(),
'fileMetadata': fileMetadata.toJson(),
});
}
Map<String, dynamic> toDatabaseJson() {
final map = toJson()
..remove('hashes')
..remove('urlSources')
..remove('suggests');
final map = toJson()..remove('fileMetadata');
return {
...map,
'hashes': jsonEncode(hashes),
'urlSources': jsonEncode(urlSources),
'suggests': jsonEncode(suggests),
'file_metadata_id': fileMetadata.id,
};
}
moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker(
moxxmpp.FileMetadataData(
mediaType: mediaType,
mediaType: fileMetadata.mimeType,
desc: desc,
size: size,
width: width,
height: height,
size: fileMetadata.size,
width: fileMetadata.width,
height: fileMetadata.height,
thumbnails: [],
hashes: hashes,
hashes: fileMetadata.plaintextHashes,
),
urlSources
fileMetadata.sourceUrls!
// Dart has some issues with using a constructor in a map
// ignore: unnecessary_lambdas
.map((src) => moxxmpp.StatelessFileSharingUrlSource(src))
.toList(),
@@ -94,5 +102,5 @@ class Sticker with _$Sticker {
);
/// 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,
name,
description,
moxxmpp.hashFunctionFromName(hashAlgorithm),
moxxmpp.HashFunction.fromName(hashAlgorithm),
hashValue,
stickers.map((sticker) => sticker.toMoxxmpp()).toList(),
restricted,

View File

@@ -1,24 +1,16 @@
import 'dart:async';
import 'dart:io';
import 'package:bloc/bloc.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:get_it/get_it.dart';
import 'package:moxlib/moxlib.dart';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/models/conversation.dart';
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.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_event.dart';
@@ -36,19 +28,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
on<FilePickerRequestedEvent>(_onFilePickerRequested);
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;
Future<void> _onInit(
@@ -135,14 +116,6 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
CurrentConversationResetEvent event,
Emitter<ConversationState> emit,
) 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(
SetOpenConversationCommand(),
awaitable: false,
@@ -209,128 +182,4 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
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';
enum SendButtonState {
/// Open the speed dial when tapped.
multi,
/// Send the current message when tapped.
send,
/// Cancel the current correction when tapped.
cancelCorrection,
/// Hide the button when we're recording an audio message.
hidden,
}
const defaultSendButtonState = SendButtonState.multi;

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get_it/get_it.dart';
import 'package:moxplatform/moxplatform.dart';
@@ -147,4 +148,10 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
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/navigation_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/pages/profile/profile.dart';
part 'profile_bloc.freezed.dart';
part 'profile_event.dart';
@@ -28,7 +29,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
if (event.isSelfProfile) {
emit(
state.copyWith(
isSelfProfile: true,
jid: event.jid!,
avatarUrl: event.avatarUrl!,
displayName: event.displayName!,
@@ -37,7 +37,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
} else {
emit(
state.copyWith(
isSelfProfile: false,
conversation: event.conversation,
),
);
@@ -45,8 +44,12 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
GetIt.I.get<NavigationBloc>().add(
PushedNamedEvent(
const NavigationDestination(
NavigationDestination(
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) {
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);
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
unawaited(FileImage(File(sticker.path)).evict());
unawaited(FileImage(File(sticker.fileMetadata.path!)).evict());
}
emit(
@@ -105,7 +105,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
for (final sticker in result.stickerPack.stickers) {
if (!sticker.isImage) continue;
sm[StickerKey(result.stickerPack.id, sticker.hashKey)] = sticker;
sm[StickerKey(result.stickerPack.id, sticker.id)] = sticker;
}
emit(
state.copyWith(
@@ -146,7 +146,7 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
for (final sticker in event.stickerPack.stickers) {
if (!sticker.isImage) continue;
sm[StickerKey(event.stickerPack.id, sticker.hashKey)] = sticker;
sm[StickerKey(event.stickerPack.id, sticker.id)] = sticker;
}
emit(

View File

@@ -1,10 +1,12 @@
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 double textfieldRadiusRegular = 15;
const double textfieldRadiusConversation = 25;
const double textfieldQuotedMessageRadius = textfieldRadiusConversation - 10;
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
top: 4,
bottom: 4,
@@ -93,6 +95,9 @@ const Color profileFallbackTextColorDark = Colors.white;
/// The text color of the buttons in the overlay of the ConversationPage
const Color conversationOverlayButtonTextColor = Color(0xffcf4aff);
/// The background color of the context menu
const Color contextMenuBackgroundColor = Color(0xff515151);
const Color settingsSectionTitleColor = Color(0xffb72fe7);
const double paddingVeryLarge = 64;
@@ -117,9 +122,18 @@ final Color sharedMediaSummaryBackgroundColor = Colors.grey.shade500;
/// displaying the download progress indicator.
final backdropBlack = Colors.black.withAlpha(150);
/// The height of the emoji/sticker picker
/// The height of the emoji/sticker picker.
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
const String cropRoute = '/crop';
const String introRoute = '/intro';

View File

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

View File

@@ -1,16 +1,23 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'dart:io';
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:moxxmpp/moxxmpp.dart';
import 'package:moxxyv2/i18n/strings.g.dart';
import 'package:moxxyv2/shared/commands.dart';
import 'package:moxxyv2/shared/constants.dart';
import 'package:moxxyv2/shared/events.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/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 {
const MessageEditingState(
@@ -37,7 +44,6 @@ class TextFieldData {
const TextFieldData(
this.isBodyEmpty,
this.quotedMessage,
this.pickerVisible,
);
/// Flag indicating whether the current text input is empty.
@@ -45,9 +51,19 @@ class TextFieldData {
/// The currently quoted message.
final Message? quotedMessage;
}
/// Flag indicating whether the picker is currently open or not.
final bool pickerVisible;
class RecordingData {
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
@@ -62,21 +78,19 @@ class BidirectionalConversationController
maxPageAmount: maxMessagePages,
) {
_textController.addListener(_handleTextChanged);
_keyboardVisibilitySubscription = KeyboardVisibilityController()
.onChange
.listen(_handleSoftKeyboardVisibilityChanged);
BidirectionalConversationController.currentController = this;
_updateChatState(ChatState.active);
}
/// Logging.
final Logger _log = Logger('BidirectionalConversationController');
/// A singleton referring to the current instance as there can only be one
/// BidirectionalConversationController at a time.
static BidirectionalConversationController? currentController;
late final StreamSubscription<bool> _keyboardVisibilitySubscription;
/// TextEditingController for the TextField
final TextEditingController _textController = TextEditingController();
TextEditingController get textController => _textController;
@@ -109,18 +123,21 @@ class BidirectionalConversationController
Stream<TextFieldData> get textFieldDataStream =>
_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
Timer? _composeTimer;
/// The last time the TextField was modified
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) {
MoxplatformPlugin.handler.getDataSender().sendData(
SendChatStateCommand(
@@ -155,12 +172,6 @@ class BidirectionalConversationController
_composeTimer = null;
}
void _handleSoftKeyboardVisibilityChanged(bool visible) {
if (visible && _pickerVisible) {
togglePickerVisibility(false);
}
}
void _handleTextChanged() {
final text = _textController.text;
if (_messageEditingState != null) {
@@ -181,7 +192,6 @@ class BidirectionalConversationController
TextFieldData(
messageBody.isEmpty,
_quotedMessage,
_pickerVisible,
),
);
@@ -206,13 +216,28 @@ class BidirectionalConversationController
Future<void> onMessageReceived(Message message) async {
// 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
// messages.
// TODO(Unknown): This is probably not the best solution
if (isFetching) {
_log.finest('Not processing message as we are currently fetching');
return;
}
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) {
// The message is older than the oldest message we know about. Drop it.
// It will be fetched when scrolling up.
@@ -264,97 +289,19 @@ class BidirectionalConversationController
);
}
/// Add [emoji] as a reaction to the message at index [index].
void addReaction(int index, String emoji) {
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) {
/// Send the sticker [sticker].
void sendSticker(sticker.Sticker sticker) {
MoxplatformPlugin.handler.getDataSender().sendData(
SendStickerCommand(
stickerPackId: packId,
stickerHashKey: hashKey,
sticker: sticker,
recipient: conversationJid,
quotes: _quotedMessage,
),
awaitable: false,
);
// Close the picker
togglePickerVisibility(false);
// Remove a possible quote
removeQuote();
}
Future<void> sendMessage(bool encrypted) async {
@@ -444,7 +391,6 @@ class BidirectionalConversationController
TextFieldData(
messageBody.isEmpty,
message,
_pickerVisible,
),
);
}
@@ -456,7 +402,6 @@ class BidirectionalConversationController
TextFieldData(
messageBody.isEmpty,
null,
_pickerVisible,
),
);
}
@@ -491,37 +436,104 @@ class BidirectionalConversationController
_sendButtonStreamController.add(conversation.defaultSendButtonState);
}
/// Toggles the visibility of the (emoji/sticker) picker
void togglePickerVisibility(bool handleKeyboard) {
final newState = !_pickerVisible;
if (handleKeyboard) {
if (newState) {
SystemChannels.textInput.invokeMethod('TextInput.hide');
} else {
SystemChannels.textInput.invokeMethod('TextInput.show');
}
Future<void> startAudioMessageRecording() async {
final status = await Permission.speech.status;
if (status.isDenied) {
await Permission.speech.request();
return;
}
_pickerVisible = newState;
_pickerVisibleStreamController.add(newState);
_textFieldDataStreamController.add(
TextFieldData(
messageBody.isEmpty,
_quotedMessage,
newState,
_recordingAudioMessageStreamController.add(
const RecordingData(
true,
false,
),
);
_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.
bool handlePop() {
if (_pickerVisible) {
togglePickerVisibility(false);
return false;
Future<void> cancelAudioMessageRecording() async {
Vibrate.feedback(FeedbackType.heavy);
_recordingAudioMessageStreamController.add(
const RecordingData(
false,
false,
),
);
_sendButtonStreamController.add(conversation.defaultSendButtonState);
_recordingStart = null;
final file = await _audioRecorder.stop();
unawaited(File(file!).delete());
}
return true;
Future<void> endAudioMessageRecording() async {
_recordingAudioMessageStreamController.add(
const RecordingData(
false,
false,
),
);
_sendButtonStreamController.add(conversation.defaultSendButtonState);
if (_recordingStart == null) {
return;
}
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
@@ -533,11 +545,16 @@ class BidirectionalConversationController
@override
void dispose() {
super.dispose();
// Reset the singleton
BidirectionalConversationController.currentController = null;
// Dispose of controllers
_textController.dispose();
_keyboardVisibilitySubscription.cancel();
_audioRecorder.dispose();
// Tell the contact that we're 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/constants.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';
class BidirectionalSharedMediaController
extends BidirectionalController<SharedMedium> {
extends BidirectionalController<Message> {
BidirectionalSharedMediaController(this.conversationJid)
: assert(
BidirectionalSharedMediaController.currentController == null,
@@ -24,11 +24,12 @@ class BidirectionalSharedMediaController
/// BidirectionalConversationController at a time.
static BidirectionalSharedMediaController? currentController;
/// The JID of the conversation we want to get shared media of.
final String conversationJid;
@override
Future<List<SharedMedium>> fetchOlderDataImpl(
SharedMedium? oldestElement,
Future<List<Message>> fetchOlderDataImpl(
Message? oldestElement,
) async {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
@@ -37,14 +38,14 @@ class BidirectionalSharedMediaController
timestamp: oldestElement?.timestamp,
olderThan: true,
),
) as PagedSharedMediaResultEvent;
) as PagedMessagesResultEvent;
return result.media;
return result.messages;
}
@override
Future<List<SharedMedium>> fetchNewerDataImpl(
SharedMedium? newestElement,
Future<List<Message>> fetchNewerDataImpl(
Message? newestElement,
) async {
// ignore: cast_nullable_to_non_nullable
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
@@ -53,9 +54,9 @@ class BidirectionalSharedMediaController
timestamp: newestElement?.timestamp,
olderThan: false,
),
) as PagedSharedMediaResultEvent;
) as PagedMessagesResultEvent;
return result.media;
return result.messages;
}
@override

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:better_open_file/better_open_file.dart';
import 'package:cryptography/cryptography.dart';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.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;
},
child: Scaffold(
appBar: BorderlessTopbar.simple(t.pages.addcontact.title),
appBar: BorderlessTopbar.title(t.pages.addcontact.title),
body: Column(
children: [
Visibility(

View File

@@ -94,11 +94,9 @@ class BlocklistPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<BlocklistBloc, BlocklistState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(
appBar: BorderlessTopbar.title(
t.pages.blocklist.title,
extra: [
Expanded(child: Container()),
PopupMenuButton(
trailing: PopupMenuButton(
onSelected: (BlocklistOptions result) async {
if (result == BlocklistOptions.unblockAll) {
final result = await showConfirmationDialog(
@@ -124,8 +122,7 @@ class BlocklistPage extends StatelessWidget {
child: Text(t.pages.blocklist.unblockAll),
),
],
)
],
),
),
body: _buildListView(state),
),

View File

@@ -1,4 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
@@ -11,37 +10,60 @@ import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
import 'package:moxxyv2/ui/helpers.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/service/data.dart';
import 'package:moxxyv2/ui/theme.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:phosphor_flutter/phosphor_flutter.dart';
class _TextFieldIconButton extends StatelessWidget {
const _TextFieldIconButton(this.icon, this.onTap);
final void Function() onTap;
final IconData icon;
const _TextFieldIconButton({
required this.keyboardController,
required this.tabController,
required this.textfieldFocusNode,
});
final KeyboardReplacerController keyboardController;
final TabController tabController;
final FocusNode textfieldFocusNode;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
onTap: () {
keyboardController.toggleWidget(context, textfieldFocusNode);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(
icon,
child: StreamBuilder<KeyboardReplacerData>(
stream: keyboardController.stream,
initialData: keyboardController.currentData,
builder: (context, snapshot) => Icon(
snapshot.data!.showWidget
? Icons.keyboard
: (tabController.index == 0
? Icons.insert_emoticon
: PhosphorIcons.stickerBold),
size: 24,
color: primaryColor,
),
),
),
);
}
}
class _TextFieldRecordButton extends StatelessWidget {
const _TextFieldRecordButton();
const _TextFieldRecordButton({
required this.conversationController,
required this.keyboardController,
});
final BidirectionalConversationController conversationController;
final KeyboardReplacerController keyboardController;
@override
Widget build(BuildContext context) {
@@ -50,15 +72,15 @@ class _TextFieldRecordButton extends StatelessWidget {
axis: Axis.vertical,
onDragStarted: () {
Vibrate.feedback(FeedbackType.heavy);
context.read<ConversationBloc>().add(
SendButtonDragStartedEvent(),
);
conversationController.startAudioMessageRecording();
keyboardController.hideWidget();
dismissSoftKeyboard(context);
},
onDraggableCanceled: (_, __) {
Vibrate.feedback(FeedbackType.heavy);
context.read<ConversationBloc>().add(
SendButtonDragEndedEvent(),
);
conversationController.endAudioMessageRecording();
},
childWhenDragging: const SizedBox(),
feedback: SizedBox(
@@ -88,26 +110,37 @@ class _TextFieldRecordButton extends StatelessWidget {
}
}
class ConversationBottomRow extends StatefulWidget {
const ConversationBottomRow(
this.tabController,
this.focusNode,
this.conversationController,
this.speedDialValueNotifier, {
class ConversationInput extends StatefulWidget {
const ConversationInput({
required this.keyboardController,
required this.conversationController,
required this.tabController,
required this.speedDialValueNotifier,
required this.isEncrypted,
required this.textfieldFocusNode,
super.key,
});
final TabController tabController;
final FocusNode focusNode;
final ValueNotifier<bool> speedDialValueNotifier;
final KeyboardReplacerController keyboardController;
final BidirectionalConversationController conversationController;
final TabController tabController;
final ValueNotifier<bool> speedDialValueNotifier;
final bool isEncrypted;
final FocusNode textfieldFocusNode;
@override
ConversationBottomRowState createState() => ConversationBottomRowState();
ConversationInputState createState() => ConversationInputState();
}
class ConversationBottomRowState extends State<ConversationBottomRow> {
class ConversationInputState extends State<ConversationInput> {
IconData _getSendButtonIcon(SendButtonState state) {
switch (state) {
case SendButtonState.hidden:
case SendButtonState.multi:
return Icons.add;
case SendButtonState.send:
@@ -117,40 +150,23 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
}
}
IconData _getPickerIcon() {
if (widget.tabController.index == 0) {
return Icons.insert_emoticon;
}
return PhosphorIcons.stickerBold;
}
@override
Widget build(BuildContext context) {
return Stack(
clipBehavior: Clip.none,
children: [
Positioned(
child: ColoredBox(
color: Colors.transparent,
child: Column(
children: [
Padding(
return ColoredBox(
color: Colors.black45,
child: Padding(
padding: const EdgeInsets.all(8),
child: BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) =>
prev.isRecording != next.isRecording,
builder: (context, state) => Row(
child: Stack(
children: [
Row(
children: [
Expanded(
child: StreamBuilder<TextFieldData>(
initialData: const TextFieldData(
true,
null,
false,
),
stream: widget
.conversationController.textFieldDataStream,
stream: widget.conversationController.textFieldDataStream,
builder: (context, snapshot) {
return CustomTextField(
backgroundColor: Theme.of(context)
@@ -168,8 +184,8 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
contentPadding: textfieldPaddingConversation,
fontSize: textFieldFontSizeConversation,
cornerRadius: textfieldRadiusConversation,
controller: widget
.conversationController.textController,
controller:
widget.conversationController.textController,
topWidget: snapshot.data!.quotedMessage != null
? buildQuoteMessageWidget(
snapshot.data!.quotedMessage!,
@@ -177,29 +193,20 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
snapshot.data!.quotedMessage!,
GetIt.I.get<UIDataService>().ownJid!,
),
resetQuote: widget
.conversationController.removeQuote,
textfieldQuotedMessageRadius,
textfieldQuotedMessageRadius,
resetQuote:
widget.conversationController.removeQuote,
)
: null,
focusNode: widget.focusNode,
shouldSummonKeyboard: () =>
!snapshot.data!.pickerVisible,
prefixIcon: IntrinsicWidth(
child: Row(
children: [
Padding(
focusNode: widget.textfieldFocusNode,
//shouldSummonKeyboard: () => !snapshot.data!.pickerVisible,
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 8),
child: _TextFieldIconButton(
snapshot.data!.pickerVisible
? Icons.keyboard
: _getPickerIcon(),
() {
widget.conversationController
.togglePickerVisibility(true);
},
),
),
],
keyboardController: widget.keyboardController,
tabController: widget.tabController,
textfieldFocusNode: widget.textfieldFocusNode,
),
),
prefixIconConstraints: const BoxConstraints(
@@ -208,15 +215,12 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
),
suffixIcon: snapshot.data!.isBodyEmpty &&
snapshot.data!.quotedMessage == null
? IntrinsicWidth(
child: Row(
children: const [
Padding(
padding:
EdgeInsets.only(right: 8),
child: _TextFieldRecordButton(),
),
],
? Padding(
padding: const EdgeInsets.only(right: 8),
child: _TextFieldRecordButton(
conversationController:
widget.conversationController,
keyboardController: widget.keyboardController,
),
)
: null,
@@ -229,21 +233,20 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
),
),
Padding(
padding: const EdgeInsets.only(left: 8),
child: AnimatedOpacity(
opacity: state.isRecording ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: IgnorePointer(
ignoring: state.isRecording,
padding: const EdgeInsets.only(left: 16),
child: SizedBox(
height: 45,
width: 45,
height: 45,
child: StreamBuilder<SendButtonState>(
initialData: defaultSendButtonState,
stream: widget
.conversationController.sendButtonStream,
builder: (context, snapshot) {
return SpeedDial(
stream: widget.conversationController.sendButtonStream,
builder: (context, snapshot) => IgnorePointer(
ignoring: snapshot.data! == SendButtonState.hidden,
child: AnimatedOpacity(
opacity:
snapshot.data! == SendButtonState.hidden ? 0 : 1,
duration: const Duration(milliseconds: 150),
child: SpeedDial(
icon: _getSendButtonIcon(snapshot.data!),
backgroundColor: primaryColor,
foregroundColor: Colors.white,
@@ -251,23 +254,18 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
SpeedDialChild(
child: const Icon(Icons.image),
onTap: () {
context
.read<ConversationBloc>()
.add(
context.read<ConversationBloc>().add(
ImagePickerRequestedEvent(),
);
},
backgroundColor: primaryColor,
foregroundColor: Colors.white,
label:
t.pages.conversation.sendImages,
label: t.pages.conversation.sendImages,
),
SpeedDialChild(
child: const Icon(Icons.file_present),
onTap: () {
context
.read<ConversationBloc>()
.add(
context.read<ConversationBloc>().add(
FilePickerRequestedEvent(),
);
},
@@ -285,12 +283,10 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
},
backgroundColor: primaryColor,
foregroundColor: Colors.white,
label:
t.pages.conversation.takePhotos,
label: t.pages.conversation.takePhotos,
),
],
openCloseDial:
widget.speedDialValueNotifier,
openCloseDial: widget.speedDialValueNotifier,
onPress: () {
switch (snapshot.data!) {
case SendButtonState.cancelCorrection:
@@ -298,21 +294,19 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
.endMessageEditing();
return;
case SendButtonState.send:
widget.conversationController
.sendMessage(
state.conversation!.encrypted,
widget.conversationController.sendMessage(
widget.isEncrypted,
);
return;
case SendButtonState.multi:
widget.speedDialValueNotifier
.value =
!widget.speedDialValueNotifier
.value;
widget.speedDialValueNotifier.value =
!widget.speedDialValueNotifier.value;
return;
case SendButtonState.hidden:
return;
}
},
);
},
),
),
),
),
@@ -320,111 +314,48 @@ class ConversationBottomRowState extends State<ConversationBottomRow> {
),
],
),
),
),
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(
left: 8,
bottom: 8,
right: 61,
child: BlocBuilder<ConversationBloc, ConversationState>(
buildWhen: (prev, next) => prev.isRecording != next.isRecording,
builder: (context, state) {
return AnimatedOpacity(
opacity: state.isRecording ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: IgnorePointer(
ignoring: !state.isRecording,
child: SizedBox(
height: textFieldFontSizeConversation + 2 * 12 + 2,
top: 0,
bottom: 0,
left: 0,
right: 45 + 16,
child: StreamBuilder<RecordingData>(
initialData: const RecordingData(
false,
false,
),
stream:
widget.conversationController.recordingAudioMessageStream,
builder: (context, snapshot) => IgnorePointer(
ignoring: !snapshot.data!.isRecording,
child: AnimatedOpacity(
opacity: snapshot.data!.isRecording ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.extension<MoxxyThemeData>()!
.conversationTextFieldColor,
borderRadius:
BorderRadius.circular(textfieldRadiusConversation),
color: Theme.of(context).scaffoldBackgroundColor,
),
// NOTE: We use a comprehension here so that the widget gets
// created and destroyed to prevent the timer from running
// until the user closes the page.
child: state.isRecording
? const Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(left: 16),
child: TimerWidget(),
),
)
: null,
padding: const EdgeInsets.only(left: 16),
child: Align(
alignment: Alignment.centerLeft,
child: snapshot.data!.isRecording
? const TimerWidget()
: const SizedBox(),
),
),
),
),
),
);
},
),
),
],
),
),
);
}
}

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

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/helpers.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/overview_menu.dart';
import 'package:moxxyv2/ui/widgets/topbar.dart';
enum ConversationsOptions { settings }
@@ -91,38 +91,77 @@ class ConversationsPage extends StatefulWidget {
class ConversationsPageState extends State<ConversationsPage>
with TickerProviderStateMixin {
late final AnimationController _controller;
late Animation<double> _convY;
/// The JID of the currently selected conversation.
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
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
_contextMenuController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
_contextMenuAnimation = Tween<double>(
begin: 0,
end: 1,
).animate(
CurvedAnimation(
parent: _contextMenuController,
curve: Curves.easeInOutCubic,
),
);
}
@override
void dispose() {
_controller.dispose();
_contextMenuController.dispose();
super.dispose();
}
Widget _listWrapper(BuildContext context, ConversationsState state) {
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
void dismissContextMenu() {
_contextMenuController.reverse();
setState(() {
_selectedConversation = null;
});
}
Widget _listWrapper(BuildContext context, ConversationsState state) {
if (state.conversations.isNotEmpty) {
return ListView.builder(
itemCount: state.conversations.length,
itemBuilder: (context, 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(
maxTextWidth,
item,
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(
@@ -131,80 +170,41 @@ class ConversationsPageState extends State<ConversationsPage>
onLongPressStart: (event) async {
Vibrate.feedback(FeedbackType.medium);
_convY = Tween<double>(
begin: event.globalPosition.dy - 20,
end: 200,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOutCubic,
),
);
final widgetRect = getWidgetPositionOnScreen(key);
final height = MediaQuery.of(context).size.height;
await _controller.forward();
setState(() {
_selectedConversation = item;
// ignore: use_build_context_synchronously
await showDialog<void>(
context: context,
builder: (context) => OverviewMenu(
_convY,
highlight: row,
left: 0,
right: 0,
children: [
if (item.unreadCounter != 0)
OverviewMenuItem(
icon: Icons.done_all,
text: t.pages.conversations.markAsRead,
onPressed: () {
context.read<ConversationsBloc>().add(
ConversationMarkedAsReadEvent(item.jid),
);
Navigator.of(context).pop();
},
),
OverviewMenuItem(
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: item.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(item.jid),
);
// ignore: use_build_context_synchronously
Navigator.of(context).pop();
final numberOptions = item.numberContextMenuOptions;
if (height - widgetRect.bottom >
40 + numberOptions * ContextMenuItem.height) {
// In this case, we have enough space below the conversation item,
// so we say that the top of the context menu is
// widgetRect.bottom (Bottom y coordinate of the conversation item)
// minus 20 (padding so we're not directly against the conversation
// item) - the height of the top bar.
_topStackOffset = widgetRect.bottom -
20 -
BorderlessTopbar.topbarPreferredHeight;
} else {
// In this case we don't have sufficient space below the conversation
// item, so we place the context menu above it.
// The computation is the same as in the above branch, but now
// we position the context menu above and thus also substract the
// height of the context menu
// (numberOptions * ContextMenuItem.height).
_topStackOffset = widgetRect.top -
20 -
numberOptions * ContextMenuItem.height -
BorderlessTopbar.topbarPreferredHeight;
}
},
),
],
),
);
});
await _controller.reverse();
await _contextMenuController.forward();
},
child: InkWell(
onTap: () => GetIt.I.get<ConversationBloc>().add(
RequestedConversationEvent(
item.jid,
item.title,
item.avatarUrl,
),
),
child: row,
),
),
);
},
);
@@ -236,9 +236,31 @@ class ConversationsPageState extends State<ConversationsPage>
Widget build(BuildContext context) {
return BlocBuilder<ConversationsBloc, ConversationsState>(
builder: (BuildContext context, ConversationsState state) => Scaffold(
appBar: BorderlessTopbar.avatarAndName(
TopbarAvatarAndName(
TopbarTitleText(state.displayName),
appBar: BorderlessTopbar(
showBackButton: false,
children: [
Expanded(
child: InkWell(
onTap: () {
// Dismiss the selection, if we have an active one
if (_selectedConversation != null) {
dismissContextMenu();
}
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(
@@ -250,17 +272,23 @@ class ConversationsPageState extends State<ConversationsPage>
),
),
),
() => GetIt.I.get<profile.ProfileBloc>().add(
profile.ProfilePageRequestedEvent(
true,
jid: state.jid,
avatarUrl: state.avatarUrl,
displayName: state.displayName,
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
state.displayName,
style: const TextStyle(
fontSize: fontsizeAppbar,
),
),
showBackButton: false,
extra: [
PopupMenuButton(
),
],
),
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: PopupMenuButton(
onSelected: (ConversationsOptions result) {
switch (result) {
case ConversationsOptions.settings:
@@ -275,11 +303,86 @@ class ConversationsPageState extends State<ConversationsPage>
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(
icon: Icons.chat,
curve: Curves.bounceInOut,

View File

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

View File

@@ -20,8 +20,13 @@ class NewConversationPage extends StatelessWidget {
),
);
Widget _renderIconEntry(IconData icon, String text, void Function() onTap) {
return InkWell(
Widget _renderIconEntry(IconData icon, String text, VoidCallback onTap) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(radiusLargeSize),
child: Material(
child: InkWell(
onTap: onTap,
child: Row(
children: [
@@ -44,14 +49,16 @@ class NewConversationPage extends StatelessWidget {
)
],
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
return Scaffold(
appBar: BorderlessTopbar.simple(t.pages.newconversation.title),
appBar: BorderlessTopbar.title(t.pages.newconversation.title),
body: BlocBuilder<NewConversationBloc, NewConversationState>(
builder: (BuildContext context, NewConversationState state) =>
ListView.builder(
@@ -87,17 +94,7 @@ class NewConversationPage extends StatelessWidget {
),
),
),
child: InkWell(
onTap: () => context.read<NewConversationBloc>().add(
NewConversationAddedEvent(
item.jid,
item.title,
item.avatarUrl,
ConversationType.chat,
),
),
child: ConversationsListRow(
maxTextWidth,
Conversation(
item.title,
Message(
@@ -110,31 +107,36 @@ class NewConversationPage extends StatelessWidget {
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,
isSelected: false,
onPressed: () => context.read<NewConversationBloc>().add(
NewConversationAddedEvent(
item.jid,
item.title,
item.avatarUrl,
ConversationType.chat,
),
),
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';
class ConversationProfileHeader extends StatelessWidget {
const ConversationProfileHeader(this.conversation, {super.key});
final Conversation conversation;
const ConversationProfileHeader({super.key});
Future<void> _showAvatarFullsize(BuildContext context, String path) async {
await showDialog<void>(
@@ -25,7 +24,7 @@ class ConversationProfileHeader extends StatelessWidget {
);
}
Widget _buildAvatar(BuildContext context) {
Widget _buildAvatar(BuildContext context, Conversation conversation) {
return RebuildOnContactIntegrationChange(
builder: () {
final path = conversation.avatarPathWithOptionalContact;
@@ -49,6 +48,9 @@ class ConversationProfileHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileBloc, ProfileState>(
builder: (context, state) {
final conversation = state.conversation!;
return Column(
children: [
Hero(
@@ -56,6 +58,7 @@ class ConversationProfileHeader extends StatelessWidget {
child: Material(
child: _buildAvatar(
context,
conversation,
),
),
),
@@ -121,5 +124,7 @@ class ConversationProfileHeader extends StatelessWidget {
),
],
);
},
);
}
}

View File

@@ -100,11 +100,9 @@ class DevicesPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<DevicesBloc, DevicesState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(
appBar: BorderlessTopbar.title(
t.pages.profile.devices.title,
extra: [
const Spacer(),
PopupMenuButton(
trailing: PopupMenuButton(
onSelected: (DevicesOptions result) {
if (result == DevicesOptions.recreateSessions) {
_recreateSessions(context);
@@ -119,7 +117,6 @@ class DevicesPage extends StatelessWidget {
)
],
),
],
),
body: _buildBody(context, state),
),

View File

@@ -180,11 +180,9 @@ class OwnDevicesPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<OwnDevicesBloc, OwnDevicesState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(
appBar: BorderlessTopbar.title(
t.pages.profile.owndevices.title,
extra: [
const Spacer(),
PopupMenuButton(
trailing: PopupMenuButton(
onSelected: (OwnDevicesOptions result) {
switch (result) {
case OwnDevicesOptions.recreateSessions:
@@ -208,7 +206,6 @@ class OwnDevicesPage extends StatelessWidget {
),
],
),
],
),
body: _buildBody(context, state),
),

View File

@@ -1,91 +1,138 @@
import 'package:flutter/material.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/profile_bloc.dart';
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
import 'package:moxxyv2/ui/constants.dart';
import 'package:moxxyv2/ui/pages/profile/conversationheader.dart';
import 'package:moxxyv2/ui/pages/profile/selfheader.dart';
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
import 'package:moxxyv2/ui/controller/shared_media_controller.dart';
import 'package:moxxyv2/ui/pages/profile/profile_view.dart';
import 'package:moxxyv2/ui/pages/profile/shared_media_view.dart';
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
class ProfileArguments {
ProfileArguments(this.isSelfProfile, this.jid);
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
builder: (_) => const ProfilePage(),
bool isSelfProfile;
/// 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(
name: profileRoute,
),
);
Widget _buildHeader(BuildContext context, ProfileState state) {
if (state.isSelfProfile) {
return SelfProfileHeader(
state.jid,
state.avatarUrl,
state.displayName,
(path, hash) => context.read<ProfileBloc>().add(
AvatarSetEvent(path, hash),
),
@override
ProfilePageState createState() => ProfilePageState();
}
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,
);
}
return ConversationProfileHeader(state.conversation!);
@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
Widget build(BuildContext context) {
return SafeArea(
child: Scaffold(
body: BlocBuilder<ProfileBloc, ProfileState>(
builder: (context, state) => Stack(
alignment: Alignment.center,
child: Stack(
children: [
ListView(
children: [
Padding(
padding: const EdgeInsets.only(top: 8),
child: _buildHeader(context, state),
Scaffold(
bottomNavigationBar: !widget.arguments.isSelfProfile
? BottomNavigationBar(
currentIndex: _pageIndex,
onTap: (index) {
_pageController.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutQuint,
);
setState(() {
_pageIndex = index;
});
},
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.person),
label: t.pages.profile.general.profile,
),
if (!state.isSelfProfile &&
state.conversation!.sharedMedia.isNotEmpty)
SharedMediaDisplay(
preview: state.conversation!.sharedMedia,
jid: state.conversation!.jid,
title: state.conversation!.titleWithOptionalContact,
sharedMediaAmount: state.conversation!.sharedMediaAmount,
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()),
),
),
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());
},
),
),
),
],
),
),
),
);
}
}

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,33 +2,32 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:moxxyv2/i18n/strings.g.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/pages/profile/profile.dart';
import 'package:moxxyv2/ui/widgets/avatar.dart';
import 'package:moxxyv2/ui/widgets/profile/options.dart';
class SelfProfileHeader extends StatelessWidget {
const SelfProfileHeader(
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;
const SelfProfileHeader(this.arguments, {super.key});
Future<void> pickAndSetAvatar(BuildContext context) async {
final avatar = await pickAvatar(context, jid, avatarUrl);
final ProfileArguments arguments;
Future<void> pickAndSetAvatar(BuildContext context, String avatarUrl) async {
final avatar = await pickAvatar(context, arguments.jid, avatarUrl);
if (avatar != null) {
setAvatar(avatar.path, avatar.hash);
// ignore: use_build_context_synchronously
context.read<ProfileBloc>().add(
AvatarSetEvent(avatar.path, avatar.hash),
);
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileBloc, ProfileState>(
builder: (context, state) {
return Column(
children: [
Hero(
@@ -36,9 +35,10 @@ class SelfProfileHeader extends StatelessWidget {
child: Material(
child: AvatarWrapper(
radius: 110,
avatarUrl: avatarUrl,
avatarUrl: state.avatarUrl,
altIcon: Icons.person,
onTapFunction: () => pickAndSetAvatar(context),
onTapFunction: () =>
pickAndSetAvatar(context, state.avatarUrl),
),
),
),
@@ -48,7 +48,7 @@ class SelfProfileHeader extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
displayName,
state.displayName,
style: const TextStyle(
fontSize: 20,
),
@@ -62,7 +62,7 @@ class SelfProfileHeader extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
jid,
arguments.jid,
style: const TextStyle(
fontSize: 15,
),
@@ -71,7 +71,8 @@ class SelfProfileHeader extends StatelessWidget {
padding: const EdgeInsetsDirectional.only(start: 3),
child: IconButton(
icon: const Icon(Icons.qr_code),
onPressed: () => showQrCode(context, 'xmpp:$jid'),
onPressed: () =>
showQrCode(context, 'xmpp:${arguments.jid}'),
),
)
],
@@ -102,5 +103,7 @@ class SelfProfileHeader extends StatelessWidget {
),
],
);
},
);
}
}

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
Widget build(BuildContext context) {
return Scaffold(
appBar: BorderlessTopbar.simple('Server Information'),
// TODO(PapaTutuWawa): Translate
appBar: BorderlessTopbar.title('Server Information'),
body: BlocBuilder<ServerInfoBloc, ServerInfoState>(
builder: (BuildContext context, ServerInfoState state) {
if (state.working) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -46,8 +46,6 @@ class ShareSelectionPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
return WillPopScope(
onWillPop: () async {
GetIt.I.get<ShareSelectionBloc>().add(ResetEvent());
@@ -67,21 +65,13 @@ class ShareSelectionPage extends StatelessWidget {
child: BlocBuilder<ShareSelectionBloc, ShareSelectionState>(
buildWhen: _buildWhen,
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(t.pages.shareselection.shareWith),
appBar: BorderlessTopbar.title(t.pages.shareselection.shareWith),
body: ListView.builder(
itemCount: state.items.length,
itemBuilder: (context, index) {
final item = state.items[index];
final isSelected = state.selection.contains(index);
return InkWell(
onTap: () {
context.read<ShareSelectionBloc>().add(
SelectionToggledEvent(index),
);
},
child: ConversationsListRow(
maxTextWidth,
return ConversationsListRow(
Conversation(
item.title,
null,
@@ -90,14 +80,12 @@ class ShareSelectionPage extends StatelessWidget {
0,
ConversationType.chat,
0,
[],
true,
true,
'',
false,
false,
ChatState.gone,
0,
contactId: item.contactId,
contactAvatarPath: item.contactAvatarPath,
contactDisplayName: item.contactDisplayName,
@@ -105,16 +93,12 @@ class ShareSelectionPage extends StatelessWidget {
false,
titleSuffixIcon: _getSuffixIcon(item),
showTimestamp: false,
extraWidgetWidth: 48,
extra: Checkbox(
value: isSelected,
onChanged: (_) {
isSelected: state.selection.contains(index),
onPressed: () {
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
Widget build(BuildContext context) {
if (sticker.path.isNotEmpty) {
if (sticker.fileMetadata.path != null) {
return Image.file(
File(sticker.path),
File(sticker.fileMetadata.path!),
fit: cover ? BoxFit.contain : null,
);
} else {
return Image.network(
sticker.urlSources.first,
sticker.fileMetadata.sourceUrls!.first,
fit: cover ? BoxFit.contain : null,
loadingBuilder: (_, child, event) {
if (event == null) return child;
@@ -214,9 +214,7 @@ class StickerPackPage extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<StickerPackBloc, StickerPackState>(
builder: (context, state) => Scaffold(
appBar: BorderlessTopbar.simple(
state.stickerPack?.name ?? '...',
),
appBar: BorderlessTopbar.title(state.stickerPack?.name ?? '...'),
body: state.isWorking
? SizedBox(
width: MediaQuery.of(context).size.width,

View File

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

View File

@@ -3,10 +3,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_vibrate/flutter_vibrate.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/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';
class RawChatBubble extends StatelessWidget {
@@ -53,8 +52,9 @@ class RawChatBubble extends StatelessWidget {
/// Specified when the message bubble should not have color
bool _shouldNotColorBubble() {
var isInlinedWidget = false;
if (message.mediaType != null) {
isInlinedWidget = message.mediaType!.startsWith('image/');
if (message.isMedia) {
isInlinedWidget =
message.fileMetadata!.mimeType?.startsWith('image/') ?? false;
}
// Check if it is a pseudo message
@@ -63,12 +63,14 @@ class RawChatBubble extends StatelessWidget {
}
// Check if it is an embedded file
if (message.isMedia && message.mediaUrl != null && isInlinedWidget) {
if (message.isMedia &&
message.fileMetadata!.path != null &&
isInlinedWidget) {
return true;
}
// Stickers are also not colored
return message.stickerPackId != null && message.stickerHashKey != null;
return message.stickerPackId != null;
}
Color _getBubbleColor(BuildContext context) {
@@ -114,6 +116,8 @@ class RawChatBubble extends StatelessWidget {
maxWidth,
borderRadius,
sentBySelf,
borderRadius.topLeft.x,
borderRadius.topRight.x,
),
),
),
@@ -129,7 +133,6 @@ class ChatBubble extends StatefulWidget {
required this.onSwipedCallback,
required this.bubble,
this.onLongPressed,
this.onReactionTap,
super.key,
});
final Message message;
@@ -142,8 +145,6 @@ class ChatBubble extends StatefulWidget {
final GestureLongPressStartCallback? onLongPressed;
// The actual message bubble
final RawChatBubble bubble;
// For acting on reaction taps
final void Function(Reaction)? onReactionTap;
@override
ChatBubbleState createState() => ChatBubbleState();
@@ -165,33 +166,6 @@ class ChatBubbleState extends State<ChatBubble>
: 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
Widget build(BuildContext context) {
super.build(context);
@@ -267,7 +241,15 @@ class ChatBubbleState extends State<ChatBubble>
child: Align(
alignment:
widget.sentBySelf ? Alignment.centerRight : Alignment.centerLeft,
child: Column(
child: Stack(
children: [
Positioned(
bottom: 10,
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
@@ -277,7 +259,18 @@ class ChatBubbleState extends State<ChatBubble>
onLongPressStart: widget.onLongPressed,
child: widget.bubble,
),
_buildReactions(),
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,
),
],
),
],
),
),

View File

@@ -1,6 +1,5 @@
import 'dart:math';
import 'dart:ui';
import 'package:moxplatform/moxplatform.dart';
import 'package:moxxyv2/shared/commands.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
/// dimensions.
Size getMediaSize(Message message, double maxWidth) {
final mediaWidth = message.mediaWidth?.toDouble();
final mediaHeight = message.mediaHeight?.toDouble();
final mediaWidth = message.fileMetadata?.width?.toDouble();
final mediaHeight = message.fileMetadata?.height?.toDouble();
var width = maxWidth;
var height = maxWidth;

View File

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

View File

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

View File

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

View File

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

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