diff --git a/docs/decisions/0050-group-chats.md b/docs/decisions/0050-group-chats.md index e5c9d7a..a92a5a7 100644 --- a/docs/decisions/0050-group-chats.md +++ b/docs/decisions/0050-group-chats.md @@ -1,7 +1,8 @@ # 0050: Group chats: profile, delivery and limits -Date: 2026-11-14. Status: **decisions accepted by the owner; implementation gated** -(see the end). Develops [0036](0036-groups-use-mls.md). The full proposal, written +Date: 2026-11-14, amended the same day (*Changes after the first emulator test*, +below). Status: **decisions +accepted by the owner; implementation gated** (see the end). Develops [0036](0036-groups-use-mls.md). The full proposal, written with GPT, is [protocol/group-chat-design.md](../protocol/group-chat-design.md); this note records the owner's choices and **amends the proposal's delivery path**. Where they differ, this note wins. @@ -18,6 +19,22 @@ The interfaces, Firestore schema, rules, function and screens are specified in | Size | 16 accounts, 4 participating devices each (64 leaves), 32 groups per account | | Dependency | OpenMLS 0.9.2 with openmls_rust_crypto 0.6.0 approved **for a spike only**; production use needs a second approval | +### Who may do what (owner, 2026-11-14) + +| Action | Who | +|---|---| +| Create a group | anyone (5 a day; at most 32 groups per account) | +| Invite someone | **any member**, but only a person they already have an accepted 1:1 chat with; the backend function checks this. The invitee must accept. | +| Add the invited person to MLS | the inviter's device, **when it sends the invitation** (amended; see below). Joining then needs no other phone. | +| Remove someone, change roles | administrators | +| Rename | administrators; other members' phones refuse anyone else's rename | +| Leave | anyone, at any time | +| Last administrator leaves | every remaining member becomes an administrator | +| End a group | no one ends it for others; it ends when its last member leaves, and the server then deletes all its records. Members keep their own copies of past messages. | +| Edit, delete for everyone | **only the message's author**, as in 1:1 chats (edits up to 10 times within 24 hours); the change shows on every member's phone | +| React | anyone | +| Delete for me | anyone, on their own phone only | + ### What the suite means - **Confidentiality is post-quantum:** every key exchange is hybrid X25519 + ML-KEM-768, @@ -78,6 +95,137 @@ document. So: Membership changes cost more under either plan and are rare. +## Changes after the first emulator test (2026-11-14) + +The owner tried groups on the Android emulator and asked for six changes. The +server's rules and membership function changed with them. + +### Joining must not wait for the inviter's phone + +MLS is end to end, so the server cannot add anyone: only a member's device can sign +the Add Commit and write the Welcome. The first version did that after the invitee +tapped Join, on the inviter's phone, while its app was open. In testing Join did +nothing until the inviter opened the app. + +Now the **invitation does the Add**. The inviter's phone is online when it invites, +so it fetches a key package for each of the invitee's devices (up to four), and +sends the Commit, the Welcome and the packages it used in the same request. + +- **Join** processes the Welcome the phone already has, so the group opens at once. + The server then moves the account from *invited* into the roster, which is what + lets the phone read and send. +- **Decline, or seven days without an answer:** the server fences the invited + devices as it does for a leave, and any member's phone sends the removing Commit + through `finalize`. The server has no scheduler, so the first member's phone to + notice an invitation past its seven days asks for the expiry. +- **Declining stays quiet.** The group sees only that the person is no longer + listed; nothing says whether they declined or the invitation ran out. +- **The trade-off.** From the invitation on, the invited devices are MLS members + and hold the keys for messages sent afterwards, before the person accepts. The + rules still refuse them everything but their own Welcome, and the app shows + nothing from before they joined. The group chose to invite them, and *Group info* + lists people who are **Invited**, apart from the members, so nobody is surprised. +- **Limits.** The 16 people and 64 devices count people who are invited. +- **Key packages run out:** the invitation is refused with "They need to open + Fireplace before they can be invited." A device that had none, or is linked + later, is added by the account's own device after it joins (`addDevice`; the + server has it, the app does not send it yet). +- **A phone that was offline** when someone was invited and then declined still + applies both Commits in order. It does not check an Add against the roster, + because the roster it can read is today's, not the one at that epoch. + +**Cost** (Blaze; all bounded by the 100 membership requests an account may make a +day): + +| | Writes | Reads | Functions | +|---|---|---|---| +| An invitation | 5 + one delete per key package (≤4) | about 13 + one per device and package (≤8); the inviter's phone reads the invitee's device list and one package each (≤5) | 1 request, 1 push | +| Joining | 4 | 3 + the account's group list; the phone reads its Welcome | 1 request | +| Declining or an expiry | 3 | 4 | 1 request, then a `finalize` (4 writes, 5 reads, 1 request) | +| A newcomer's colours | one message from the newcomer and one from each member (≤16 messages, 2 writes each) | | ≤16 push triggers, throttled per person | +| A member applying a membership Commit | | 1 extra read of the roster | | + +### Colours + +Your own messages use your primary colour; every other member's use the soft tint +of **that member's** primary colour, and their name and avatar its text shade. The +colour travels only inside the group, as an encrypted `profile` message. + +The first version sent a profile only on creating or joining a group or changing +the colour, so a later joiner never received the earlier members' colours and saw +them in Stone. Now a newcomer's profile is marked `hello`, and **every member +answers a `hello` with their own profile**, once. Colours are kept per group, and +avatars use them too. + +### The start of the history + +Someone who joins a group that already existed sees a quiet line above their first +message: *Messages sent before you joined aren’t shown.* The phone reads and shows +nothing from before the time the server recorded the join. + +### The same look as 1:1 chats + +The group row reuses the 1:1 row's layout (avatar size, bold name, date, two-line +preview, hairline). Its avatar is a **circle** with the group's initial, like a +person's: the owner changed this from the first version's rounded square after +seeing it on the emulator (2026-11-14). The conversation reuses the +1:1 bubble, held-message menu, reactions, day separators and composer, and editing +happens in the composer, not a dialog. The differences are the sender's name above +the first bubble of a run, their avatar beside the last, and the colours. + +### Unread counts, lines and pushes + +- **Unread count** on the group row, counted on the phone from the local history + with the same per-chat record of what has been shown as 1:1 chats. No server + field and no read receipt. Group messages count on the app icon's badge too. +- **Muting a group** works as for a 1:1 chat: hold the row, or use *Group info*. + It is kept on the phone with the 1:1 mutes; the server and the other members + are not told. A muted group keeps its count on the row, in grey, and is left + out of the app icon's badge. Pushes are unchanged, as for a muted 1:1 chat. +- **Lines in the conversation**, small and centred: *alice added bob_jones*, + *mira left*, *alice removed mira*, *alice made bob_jones an administrator*, + *alice renamed the group*. They are built on the phone from the MLS state before + and after each **authenticated Commit**, never from the server's roster. + - A removal looks the same in MLS whether the person left or was removed. So a + member who leaves first says so in an encrypted group message, and an + administrator who removes someone says that; MLS vouches for the sender. With + neither (a declined invitation, a deleted account) the line reads *… is no + longer in the group*. + - The lines are stored in the encrypted local history, are never counted as + unread and are never matched by search. +- **Push when someone is invited:** one, to the invited person only (owner's + choice), following their existing notification settings: *Fireplace / You were + invited by alice.* when the sender's name is shown, *Fireplace / New Invitation* + when it is hidden. The owner asked for the group's name as the title; the server + never has it, so that waits for the phone to read the Welcome in the background. + +### Administrators + +In *Group info* an administrator taps a member for **Make administrator** or +**Remove as administrator**. It is an MLS Commit carrying the new list (group-context +extension `0xF0F1`) and the same list to the server (`roles`). Every phone refuses +a role change, like a rename, from someone who is not an administrator. Someone +invited cannot be made one until they join. If the last administrator leaves, +everyone left is one, as before. + +### Tests + +`test/model/groups/group_service_test.dart` (23, two and three phones on the real +Rust core): joining with the inviter's phone off, key packages run out, expiry, +decline, removing someone invited, colours for a later joiner, each line, roles, +and a refused role change. `test/view/groups/group_screens_test.dart` (11): the +1:1 menu and composer editing, colours of bubbles, names and avatars, the lines, +the start-of-history line, the unread count, muting, Make administrator, and +*Invited*. +`scripts/render/render_groups_test.dart` renders the screens for review. + +### Not in this round + +- The invitation card inside the 1:1 chat with the inviter (it shows in Message + requests): [issue #164](https://github.com/fireplacechat/mobile/issues/164). +- `addDevice` from the app. +- The group's name in the invitation push. + ## Spike results [fireplace-crypto#3](https://github.com/fireplacechat/fireplace-crypto/pull/3) diff --git a/docs/protocol/group-chats-v1.md b/docs/protocol/group-chats-v1.md index 001173c..50d8422 100644 --- a/docs/protocol/group-chats-v1.md +++ b/docs/protocol/group-chats-v1.md @@ -1,6 +1,8 @@ # Group chats, version 1: specification -Date: 2026-11-14. Status: **design for review**. Implements +Date: 2026-11-14. Status: **design for review**; sections 2 to 4 were amended by +*Changes after the first emulator test* in decision 0050 (the invitation carries the Add; `hello` profiles; +lines; roles), and where they differ that note wins. Implements [decision 0050](../decisions/0050-group-chats.md) and the [proposal](group-chat-design.md) it amends. Fixes the interfaces three pieces of work will build against at the same time: the Rust core, the backend, and the app. @@ -58,7 +60,8 @@ Handle-based and bounded, wiping buffers with `fp_free`, like `FpSession`: A staged commit's summary lists the leaves added, removed and updated and the new name and roles, so the app can check it against the server roster and its rules -(only administrators change membership, names and roles) before merging. +(only administrators remove people and change names and roles; any member's device +may add someone whose invitation was accepted) before merging. The **name and roles** live in an application-defined group-context extension, changed only by Commits. They are therefore encrypted, authenticated by MLS, and included in a @@ -126,9 +129,10 @@ challenge, roles and limits. | Operation | Who | What the transaction does | |---|---|---| | `create` | any account (5 a day) | `groups/{gid}`, creator as administrator, `groupRefs` | -| `invite(uid)` | administrator | `groups/{gid}/invites/{uid}`, refused if either has blocked the other; at most 20 pending per recipient | -| `accept` / `decline` | the invitee | marks the invite | -| `add(uid, epoch, commit, welcomes, packages)` | an administrator's device, after `accept` | checks `epoch`, consumes the packages, writes the Commit, the Welcomes, `members` and `groupRefs`, and raises `epoch`, all at once | +| `invite(uid, devices, epoch, commit, welcome, packages)` | any member's device | needs an accepted 1:1 chat between the two; refused if either has blocked the other; at most 20 pending per recipient. Checks `epoch`, consumes the packages, writes the Commit and the Welcome, records the account in `invited`, and raises `epoch`, all at once | +| `accept` | the invitee | moves the account from `invited` to `members`, writes `groupRefs` with `joinedAt`. No Commit | +| `decline`, `expire(uid)` | the invitee; any member after 7 days | removes the account from `invited` and fences its devices | +| `roles(admins, epoch, commit)` | administrator | the Commit carrying the new list; sets each member's `role` | | `addDevice(device, …)` | the same account's other device | the same, for a newly linked device | | `remove(uid, epoch, commit)` | administrator | removes every leaf of that account | | `leave` | a member | removes it from `members` and `groupRefs` at once (it can no longer read) and adds its leaves to `fence` | @@ -172,8 +176,9 @@ devices, rechecking fences, as `pushOnMessage` does. group*. *New group* asks for a name (up to 40 characters) and lets you tick people from your accepted chats, at most 15 besides you, then *Create group*. The people are invited, not added: each joins when they accept. -- **In the chat list.** A group's avatar is its name's first letter in a rounded - square, so groups have a different shape from people's round avatars. The preview +- **In the chat list.** A group's avatar is its name's first letter in a circle, + like a person's (the first version used a rounded square; the owner changed it in + the emulator test). The preview names the sender: *Mira: See you at 7?* A group you've been invited to doesn't appear until you join. - **Invitations** show in both places: @@ -186,8 +191,11 @@ devices, rechecking fences, as `pushOnMessage` does. the first bubble and a small avatar beside the last. The header shows the group name and the number of people. Tapping it opens *Group info*: - the members and who are administrators; - - for administrators: *Add people*, *Rename* and *Remove*; - - for everyone: *Leave*. + - for everyone: *Add people* (from your 1:1 contacts) and *Leave*; + - for administrators also: *Rename* and *Remove*. +- **Message actions.** Hold a message: react (anyone); *Edit* and *Delete for + everyone* (your own messages only; every member's phone applies them only from the + author); *Delete for me* (any message, this phone only). - **Colours.** - **Your** messages use your primary chat colour, as in 1:1 chats. - **Each other member's** messages use the secondary (soft) version of *their own* @@ -195,7 +203,10 @@ devices, rechecking fences, as `pushOnMessage` does. chose Plum, everyone sees her bubbles in the plum tint. - A member's colour is shared only inside the group, as an encrypted `profile` message (`type: profile, color`) sent on joining and whenever they change it. The - server never stores it, and 1:1 chats are unchanged. + server never stores it, and 1:1 chats are unchanged. A newcomer's is marked + `hello: true`, and each member answers it with their own. + - `type: notice` messages (`kind: left`, or `kind: removing, uid` from an + administrator) say why a removal is about to happen, for the line it shows. - A member whose colour hasn't arrived yet shows in Stone. - **System lines**, small and centred: *Rowan added Mira*, *Elliot left*, *Rowan renamed the group*. They come from the authenticated Commits, never from the diff --git a/docs/reviews/group-chats/README.md b/docs/reviews/group-chats/README.md new file mode 100644 index 0000000..b5233a1 --- /dev/null +++ b/docs/reviews/group-chats/README.md @@ -0,0 +1,25 @@ +# Group chats: screen previews + +Widget renders of the group screens (decision [0050](../../decisions/0050-group-chats.md)), +in both themes, at 390 x 844 points. They are produced by + +```sh +bash scripts/build_crypto_host.sh +TZ=UTC flutter test scripts/render/render_groups_test.dart +``` + +which runs real group services over the Rust MLS core, so times and ids differ +between runs. The names and messages are fictional. These are renders, not device +screenshots. + +| State | Light | Dark | +|---|---|---| +| Chat list: groups beside 1:1 chats, with unread counts | [light-list.png](light-list.png) | [dark-list.png](dark-list.png) | +| Conversation: names, avatars and colours per member; lines for changes | [light-conversation.png](light-conversation.png) | [dark-conversation.png](dark-conversation.png) | +| Held message: the 1:1 menu and reactions | [light-conversation-menu.png](light-conversation-menu.png) | [dark-conversation-menu.png](dark-conversation-menu.png) | +| Editing in the composer | [light-conversation-editing.png](light-conversation-editing.png) | [dark-conversation-editing.png](dark-conversation-editing.png) | +| Someone who joined later | [light-joined-later.png](light-joined-later.png) | [dark-joined-later.png](dark-joined-later.png) | +| Group info: mute, members, roles, people invited | [light-info.png](light-info.png) | [dark-info.png](dark-info.png) | + +The reaction bar in the held-message previews shows empty boxes: the SDK preview +font has no emoji (see `scripts/render/README.md`). diff --git a/docs/reviews/group-chats/dark-conversation-editing.png b/docs/reviews/group-chats/dark-conversation-editing.png new file mode 100644 index 0000000..73a66f2 Binary files /dev/null and b/docs/reviews/group-chats/dark-conversation-editing.png differ diff --git a/docs/reviews/group-chats/dark-conversation-menu.png b/docs/reviews/group-chats/dark-conversation-menu.png new file mode 100644 index 0000000..53103d4 Binary files /dev/null and b/docs/reviews/group-chats/dark-conversation-menu.png differ diff --git a/docs/reviews/group-chats/dark-conversation.png b/docs/reviews/group-chats/dark-conversation.png new file mode 100644 index 0000000..b42bf47 Binary files /dev/null and b/docs/reviews/group-chats/dark-conversation.png differ diff --git a/docs/reviews/group-chats/dark-info.png b/docs/reviews/group-chats/dark-info.png new file mode 100644 index 0000000..4f20cb0 Binary files /dev/null and b/docs/reviews/group-chats/dark-info.png differ diff --git a/docs/reviews/group-chats/dark-joined-later.png b/docs/reviews/group-chats/dark-joined-later.png new file mode 100644 index 0000000..18cc921 Binary files /dev/null and b/docs/reviews/group-chats/dark-joined-later.png differ diff --git a/docs/reviews/group-chats/dark-list.png b/docs/reviews/group-chats/dark-list.png new file mode 100644 index 0000000..ae9a7c6 Binary files /dev/null and b/docs/reviews/group-chats/dark-list.png differ diff --git a/docs/reviews/group-chats/light-conversation-editing.png b/docs/reviews/group-chats/light-conversation-editing.png new file mode 100644 index 0000000..cf39a6a Binary files /dev/null and b/docs/reviews/group-chats/light-conversation-editing.png differ diff --git a/docs/reviews/group-chats/light-conversation-menu.png b/docs/reviews/group-chats/light-conversation-menu.png new file mode 100644 index 0000000..6bb494f Binary files /dev/null and b/docs/reviews/group-chats/light-conversation-menu.png differ diff --git a/docs/reviews/group-chats/light-conversation.png b/docs/reviews/group-chats/light-conversation.png new file mode 100644 index 0000000..6ab0216 Binary files /dev/null and b/docs/reviews/group-chats/light-conversation.png differ diff --git a/docs/reviews/group-chats/light-info.png b/docs/reviews/group-chats/light-info.png new file mode 100644 index 0000000..f965f2c Binary files /dev/null and b/docs/reviews/group-chats/light-info.png differ diff --git a/docs/reviews/group-chats/light-joined-later.png b/docs/reviews/group-chats/light-joined-later.png new file mode 100644 index 0000000..ce73421 Binary files /dev/null and b/docs/reviews/group-chats/light-joined-later.png differ diff --git a/docs/reviews/group-chats/light-list.png b/docs/reviews/group-chats/light-list.png new file mode 100644 index 0000000..80401b2 Binary files /dev/null and b/docs/reviews/group-chats/light-list.png differ diff --git a/lib/src/app/group_providers.dart b/lib/src/app/group_providers.dart new file mode 100644 index 0000000..1021383 --- /dev/null +++ b/lib/src/app/group_providers.dart @@ -0,0 +1,162 @@ +import 'dart:async'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/chat_color_preference.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/model/groups/group_service.dart'; +import 'package:fireplace/src/styles/chat_colors.dart'; + +/// The signed-in account's group service, or null (groups off or not ready). +final groupServiceProvider = Provider( + (ref) => ref.watch(appSessionProvider).value?.groups, +); + +/// Whether this session offers groups (a build with FIREPLACE_GROUPS whose +/// group service started). +final groupsOnProvider = Provider( + (ref) => ref.watch(groupServiceProvider) != null, +); + +final groupsProvider = StreamProvider.autoDispose>(( + ref, +) { + final service = ref.watch(groupServiceProvider); + return service == null ? Stream.value(const {}) : service.watchGroups(); +}); + +final groupInvitationsProvider = + StreamProvider.autoDispose>((ref) { + final service = ref.watch(groupServiceProvider); + return service == null + ? Stream.value(const []) + : service.watchInvitations(); + }); + +final groupMessagesProvider = StreamProvider.autoDispose + .family, String>((ref, gid) { + final service = ref.watch(groupServiceProvider); + return service == null + ? Stream.value(const []) + : service.watchMessages(gid); + }); + +/// Each member's colour name in one group, updated as profile messages arrive. +final groupColorsProvider = StreamProvider.autoDispose + .family((ref, gid) async* { + final service = ref.watch(groupServiceProvider); + if (service == null) { + yield const {}; + return; + } + yield service.colorsOf(gid); + await for (final changed in service.colorChanges) { + if (changed == gid) yield service.colorsOf(gid); + } + }); + +/// People invited to a group who have not answered, with who invited each. +/// From the server's roster: MLS already counts them as members. +final groupInvitedProvider = StreamProvider.autoDispose + .family, String>((ref, gid) async* { + final service = ref.watch(groupServiceProvider); + if (service == null) { + yield const {}; + return; + } + yield service.invitedTo(gid); + await for (final changed in service.rosterChanges) { + if (changed == gid) yield service.invitedTo(gid); + } + }); + +/// The colour to show for a member: theirs once it has arrived, Stone before. +ChatBubbleColor memberColor(MemberColors colors, String uid) => + ChatBubbleColor.values.asNameMap()[colors[uid]] ?? ChatBubbleColor.stone; + +/// Messages from other people in a group that this phone has not shown yet. +/// Counted here from the local history, like 1:1 chats: the server holds no +/// read state and nobody is told what was read. +final groupUnreadProvider = Provider.autoDispose.family(( + ref, + gid, +) { + final prefs = ref.watch(appSessionProvider).value?.chatPreferences; + if (prefs == null || !prefs.available) return 0; + final changes = prefs.changes.listen((_) => ref.invalidateSelf()); + ref.onDispose(changes.cancel); + final id = groupChatId(gid); + final incoming = [ + for (final m + in ref.watch(groupMessagesProvider(gid)).value ?? + const []) + if (!m.outgoing && + m.visible && + !m.remotelyDeleted && + m.status == MessageStatus.ok) + m.id, + ]; + if (prefs.needsBaseline(id)) { + // What is already here when the group first appears is not "new". + unawaited(prefs.seedExisting(id, incoming).catchError((Object _) {})); + return 0; + } + final seen = prefs.seen[id] ?? const {}; + return incoming.where((m) => !seen.contains(m)).length; +}); + +/// Whether this phone has muted a group. Kept with the 1:1 mutes, on the +/// phone only: the server and the other members never learn of it. +final groupMutedProvider = Provider.autoDispose.family(( + ref, + gid, +) { + final prefs = ref.watch(appSessionProvider).value?.chatPreferences; + if (prefs == null) return false; + final changes = prefs.changes.listen((_) => ref.invalidateSelf()); + ref.onDispose(changes.cancel); + return prefs.muted.contains(groupChatId(gid)); +}); + +/// Unread group messages for the app icon's badge: muted groups excluded, as +/// muted 1:1 chats are. +final groupsBadgeProvider = Provider.autoDispose((ref) { + var total = 0; + for (final gid in (ref.watch(groupsProvider).value ?? const {}).keys) { + if (!ref.watch(groupMutedProvider(gid))) { + total += ref.watch(groupUnreadProvider(gid)); + } + } + return total; +}); + +/// Mutes or unmutes a group on this phone. +Future setGroupMuted(WidgetRef ref, String gid, bool muted) async { + await ref + .read(appSessionProvider) + .value + ?.chatPreferences + .mute(groupChatId(gid), muted); +} + +/// Shares a new primary colour with every group when it changes in Settings. +final groupColorSyncProvider = Provider((ref) { + ref.listen(chatBubbleColorsProvider, (before, after) { + final service = ref.read(groupServiceProvider); + if (service == null || before?.outgoing == after.outgoing) return; + service.ownColor = after.outgoing.name; + unawaited(service.shareColor()); + }); +}); + +/// People this account can put in a group: accepted 1:1 chats, not blocked. +/// The server checks the same thing for every invitation. +final groupContactsProvider = Provider.autoDispose>((ref) { + final chats = ref.watch(chatsProvider).value ?? const []; + final blocked = ref.watch(blockedUidsProvider).value ?? const {}; + return [ + for (final c in chats) + if (c.accepted && !blocked.contains(c.peerUid)) c.peerUid, + ]; +}); diff --git a/lib/src/app/providers.dart b/lib/src/app/providers.dart index 4ff4d7b..4cdd323 100644 --- a/lib/src/app/providers.dart +++ b/lib/src/app/providers.dart @@ -2,6 +2,11 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:fireplace/src/app/chat_color_preference.dart'; +import 'package:fireplace/src/model/groups/group_models.dart' + show groupsEnabled; +import 'package:fireplace/src/model/groups/group_service.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -127,6 +132,7 @@ class AppSession { required this.keys, required this.safety, this.pushNotifications, + this.groups, LocalChatPreferences? chatPreferences, required StreamSubscription chatsSub, required this.dispose, @@ -142,6 +148,9 @@ class AppSession { final KeyService keys; final SafetyService safety; final PushNotificationService? pushNotifications; + + /// Group chats (decision 0050); null unless built with FIREPLACE_GROUPS. + final GroupService? groups; final StreamSubscription _chatsSub; final Future Function() dispose; @@ -321,6 +330,32 @@ final appSessionProvider = FutureProvider((ref) async { ); scope.add(onMessage.cancel); } + // 9b. Group chats (decision 0050), only in builds with FIREPLACE_GROUPS. + // A failure here leaves groups unavailable and 1:1 chats untouched. + GroupService? groups; + if (groupsEnabled) { + try { + final service = GroupService( + db: db, + uid: user.uid, + device: device, + secrets: secrets, + historyDir: historyDir, + messages: store, + )..ownColor = ref.read(chatBubbleColorsProvider).outgoing.name; + await service.start(); + scope.add(service.close); + groups = service; + } catch (e, st) { + // Debug builds only: release builds log nothing about the account. + if (kDebugMode) { + debugPrint('Groups unavailable: $e'); + debugPrintStack(stackTrace: st); + } + groups = null; + } + scope.checkActive(); + } // 10. Return the session with cleanup and local-data removal. final session = AppSession( uid: user.uid, @@ -330,6 +365,7 @@ final appSessionProvider = FutureProvider((ref) async { keys: keys, safety: safety, pushNotifications: pushNotifications, + groups: groups, chatPreferences: chatPreferences, chatsSub: chatsSub, destroyLocalData: () async { diff --git a/lib/src/crypto/backend.dart b/lib/src/crypto/backend.dart index 64d154d..ad57aae 100644 --- a/lib/src/crypto/backend.dart +++ b/lib/src/crypto/backend.dart @@ -56,6 +56,10 @@ NativeCrypto get _nativeCrypto => _core ??= NativeCrypto( ), ); +/// One device's MLS group store (decision 0050); groups use the Rust core only. +NativeGroupStore openNativeGroupStore(List records) => + _nativeCrypto.openGroupStore(records); + T _sessionOperation(T Function() operation) { try { return operation(); diff --git a/lib/src/crypto/group_crypto.dart b/lib/src/crypto/group_crypto.dart new file mode 100644 index 0000000..48ff5d9 --- /dev/null +++ b/lib/src/crypto/group_crypto.dart @@ -0,0 +1,45 @@ +import 'dart:typed_data'; + +import 'package:fireplace/src/crypto/backend.dart' show openNativeGroupStore; +import 'package:fireplace_native/fireplace_crypto.dart'; + +/// A refused or malformed group operation. [invalid] is bad input; otherwise +/// the core refused it on protocol grounds (a forged credential, a Commit for +/// another epoch, a message it cannot decrypt). +class GroupCryptoException implements Exception { + GroupCryptoException(this.code); + final int code; + bool get invalid => code == 1; + @override + String toString() => 'Group operation refused ($code).'; +} + +/// One device's MLS store (mobile decision 0050). Groups use the Rust core +/// only: there is no Dart implementation of MLS. +/// +/// A changing request returns `changes` and leaves the store open until +/// [accept] or [rollback]. The caller saves the changes in between, so a crash +/// before [accept] reloads the old records and loses nothing. +class GroupCrypto { + GroupCrypto._(this._native); + + /// Opens the store from saved records (empty for a new device). + factory GroupCrypto.open(List records) => + GroupCrypto._(openNativeGroupStore(records)); + + final NativeGroupStore _native; + + Map call(Map request) { + try { + return _native.call(request); + } on NativeCryptoException catch (e) { + throw GroupCryptoException(e.code); + } + } + + void accept() => call({'op': 'accept'}); + void rollback() => call({'op': 'rollback'}); + Uint8List records() => _native.records(); + void close() => _native.close(); + bool get isClosed => _native.isClosed; +} diff --git a/lib/src/db/account_erasure.dart b/lib/src/db/account_erasure.dart index 7e8f603..0d22478 100644 --- a/lib/src/db/account_erasure.dart +++ b/lib/src/db/account_erasure.dart @@ -31,6 +31,8 @@ Future eraseAccountSecrets(SecretStore store, String uid) async { 'device', 'bundle', 'msgkey', + 'groupkey', + 'groupcolors', 'chatprefskey', 'hidden', 'pushasked', diff --git a/lib/src/db/group_record_file.dart b/lib/src/db/group_record_file.dart new file mode 100644 index 0000000..ff13502 --- /dev/null +++ b/lib/src/db/group_record_file.dart @@ -0,0 +1,119 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:fireplace/src/crypto/codec.dart'; +import 'package:fireplace/src/db/secret_store.dart'; + +/// The device's MLS records (mobile decision 0050), encrypted with their own +/// account key (`groupkey:{uid}`) in one file in the account's history folder, +/// so account erasure removes it with the history. +/// +/// The records are kept in memory as a map; [apply] takes the changes the core +/// returned and [save] writes the whole set atomically (a temporary file, then +/// a rename), so a crash leaves either the old records or the new ones. +class GroupRecordFile { + GroupRecordFile._(this._file, this._key, this._aad, this._records); + + static final _aead = AesGcm.with256bits(); + + final File _file; + final SecretKey _key; + final List _aad; + final Map _records; + + static String keyName(String uid) => 'groupkey:$uid'; + + static Future open({ + required Directory historyDir, + required SecretStore secrets, + required String uid, + }) async { + var raw = await secrets.read(keyName(uid)); + if (raw == null) { + raw = b64(randomBytes(32)); + await secrets.write(keyName(uid), raw); + } + await historyDir.create(recursive: true); + final file = File('${historyDir.path}/groups.bin'); + final key = SecretKey(unb64(raw)); + final aad = utf8Bytes('fireplace-groups-v1:$uid'); + final records = {}; + if (await file.exists()) { + final data = await file.readAsBytes(); + if (data.length < 28) { + throw const FormatException('Group records damaged.'); + } + final box = SecretBox( + data.sublist(12, data.length - 16), + nonce: data.sublist(0, 12), + mac: Mac(data.sublist(data.length - 16)), + ); + final plain = await _aead.decrypt(box, secretKey: key, aad: aad); + final parts = plain.isEmpty ? const [] : unlp(plain); + if (parts.length.isOdd) { + throw const FormatException('Group records damaged.'); + } + for (var i = 0; i < parts.length; i += 2) { + records[b64(parts[i])] = parts[i + 1]; + } + } + return GroupRecordFile._(file, key, aad, records); + } + + /// The records in the core's blob form: `lp(key, value, ...)`, keys sorted. + Uint8List blob() { + final keys = _records.keys.toList()..sort(); + if (keys.isEmpty) return Uint8List(0); + return lp([ + for (final k in keys) ...[unb64(k), _records[k]!], + ]); + } + + bool get isEmpty => _records.isEmpty; + + // App records (cursors) live beside the core's, under `app:` names, and are + // saved in the same atomic write. The core ignores them. + static String _appKey(String name) => b64(utf8Bytes('app:$name')); + + Uint8List? app(String name) => _records[_appKey(name)]; + + void setApp(Map?> values) { + for (final e in values.entries) { + final v = e.value; + if (v == null) { + _records.remove(_appKey(e.key)); + } else { + _records[_appKey(e.key)] = Uint8List.fromList(v); + } + } + } + + /// Applies `changes` from a core result: `[{k, v | null}]`, base64. + void apply(Object? changes) { + if (changes is! List) return; + for (final c in changes) { + final k = (c as Map)['k'] as String; + final v = c['v']; + if (v == null) { + _records.remove(k); + } else { + _records[k] = unb64(v as String); + } + } + } + + Future save() async { + final nonce = randomBytes(12); + final box = await _aead.encrypt( + blob(), + secretKey: _key, + nonce: nonce, + aad: _aad, + ); + final bytes = concat([nonce, box.cipherText, box.mac.bytes]); + final tmp = File('${_file.path}.tmp'); + await tmp.writeAsBytes(bytes, flush: true); + await tmp.rename(_file.path); + } +} diff --git a/lib/src/db/local_messages.dart b/lib/src/db/local_messages.dart index 02b526c..50dc64c 100644 --- a/lib/src/db/local_messages.dart +++ b/lib/src/db/local_messages.dart @@ -14,6 +14,11 @@ enum MessageStatus { /// server. Shown with a warning; never treated as sent, and resolved only by evidence from the /// server (see ChatService.checkSendStatus) or by an explicit decision to send another copy. unconfirmed, + + /// A line this device wrote into a group's history from an authenticated + /// change ("alice added bob_jones"). The body is a `GroupNotice` in JSON, + /// never text a person typed. + notice, } class LocalMessage { diff --git a/lib/src/model/groups/group_models.dart b/lib/src/model/groups/group_models.dart new file mode 100644 index 0000000..96c448c --- /dev/null +++ b/lib/src/model/groups/group_models.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; + +/// Group chats (mobile decision 0050, `docs/protocol/group-chats-v1.md`). +/// +/// Off unless the app is built with `--dart-define=FIREPLACE_GROUPS=true`, so +/// release builds have no groups until the release gates in 0050 are met. +const groupsEnabled = bool.fromEnvironment('FIREPLACE_GROUPS'); + +abstract final class GroupLimits { + static const maxMembers = 16; + static const maxNameChars = 40; + static const keyPackagesPerDevice = 10; +} + +/// The local history's chat id for a group. Never passed to the 1:1 code. +String groupChatId(String gid) => 'group-$gid'; +bool isGroupChatId(String chatId) => chatId.startsWith('group-'); +String gidOfChat(String chatId) => chatId.substring('group-'.length); + +/// One member of a group, as MLS authenticated it. +class GroupMember { + const GroupMember({ + required this.uid, + required this.devices, + required this.admin, + }); + final String uid; + final List devices; + final bool admin; +} + +/// What this device knows of a group: from its MLS state, never from the +/// server's roster alone. +class GroupInfo { + const GroupInfo({ + required this.gid, + required this.name, + required this.epoch, + required this.members, + required this.active, + }); + final String gid; + final String name; + final int epoch; + final List members; + + /// False once this device has been removed. + final bool active; + + String get chatId => groupChatId(gid); + bool isAdmin(String uid) => + members.any((m) => m.uid == uid && m.admin) || + // When nobody administers the group, everybody does (as the server). + !members.any((m) => m.admin); + + static GroupInfo fromCore(Map j) { + final admins = {...(j['admins'] as List).cast()}; + final byUid = >{}; + for (final m in (j['members'] as List).cast()) { + byUid + .putIfAbsent(m['uid'] as String, () => []) + .add(m['device'] as String); + } + return GroupInfo( + gid: j['gid'] as String, + name: j['name'] as String, + epoch: j['epoch'] as int, + active: j['active'] as bool, + members: [ + for (final e in byUid.entries) + GroupMember( + uid: e.key, + devices: e.value, + admin: admins.contains(e.key), + ), + ], + ); + } +} + +/// A line in a group's history about a change to the group (decision 0050). +/// Built on this device from the MLS state before and after an authenticated +/// Commit, never from the server's roster. Stored as a history message whose +/// status is `notice`; the screens put names in when they show it. +enum GroupNoticeKind { + added, + removed, + left, + gone, + madeAdmin, + removedAdmin, + renamed, +} + +class GroupNotice { + const GroupNotice(this.kind, {this.by, this.who}); + final GroupNoticeKind kind; + + /// The account that made the change (the Commit's author), when it matters. + final String? by; + + /// The account the change is about. + final String? who; + + String encode() => jsonEncode({ + 'notice': kind.name, + if (by != null) 'by': by, + if (who != null) 'who': who, + }); + + static GroupNotice? decode(String body) { + try { + final j = jsonDecode(body); + if (j is! Map) return null; + final kind = GroupNoticeKind.values.asNameMap()[j['notice']]; + if (kind == null) return null; + return GroupNotice( + kind, + by: j['by'] as String?, + who: j['who'] as String?, + ); + } catch (_) { + return null; + } + } + + /// The line in plain words. [name] turns an account id into what to show + /// ("You" for this account). + String text(String Function(String uid) name) { + final a = by == null ? '' : name(by!); + final b = who == null ? '' : name(who!); + return switch (kind) { + GroupNoticeKind.added => '$a added $b', + GroupNoticeKind.removed => '$a removed $b', + GroupNoticeKind.left => '$b left', + GroupNoticeKind.gone => + b == 'You' + ? 'You are no longer in the group' + : '$b is no longer in the group', + GroupNoticeKind.madeAdmin => '$a made $b an administrator', + GroupNoticeKind.removedAdmin => '$a removed $b as an administrator', + GroupNoticeKind.renamed => '$a renamed the group', + }; + } +} + +/// An invitation waiting for this account to answer. +class GroupInvitation { + const GroupInvitation({ + required this.gid, + required this.inviter, + required this.at, + }); + final String gid; + final String inviter; + final DateTime at; +} + +/// Each member's chosen colour name (a ChatBubbleColor's name), shared only +/// inside the group (0050). +typedef MemberColors = Map; + +/// A membership request the server refused, with its reason. +class GroupRefused implements Exception { + GroupRefused(this.reason); + final String reason; + @override + String toString() => 'Group request refused ($reason).'; + + /// Plain words for people. + String get message => switch (reason) { + 'full' => 'This group is full.', + 'limit' => + 'You have made the most groups allowed today. Try again tomorrow.', + 'too many groups' => 'You are in the most groups allowed.', + 'not an administrator' => 'Only administrators can do that.', + 'not a contact' => 'You can invite only people you already chat with.', + 'expired' => 'This invitation has expired.', + 'no invite' => 'This invitation is no longer open.', + 'already invited' => 'They have already been invited.', + 'packages' || 'devices' => 'They need to open Fireplace before they can be invited. Try again later.', + 'epoch' => 'The group changed just then. Try again.', + 'admins' => 'Only people who have joined can be administrators.', + _ => 'That did not work. Try again.', + }; +} + +/// An action this device refused before sending, in plain words. +class GroupActionError implements Exception { + GroupActionError(this.message); + final String message; + @override + String toString() => message; +} diff --git a/lib/src/model/groups/group_requests.dart b/lib/src/model/groups/group_requests.dart new file mode 100644 index 0000000..ce15466 --- /dev/null +++ b/lib/src/model/groups/group_requests.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:fireplace/src/crypto/codec.dart'; +import 'package:fireplace/src/model/common/daily_counter.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; + +/// Membership requests (decision 0050): a document the server's +/// function completes with `status: done | refused`. Each is counted in +/// `limits/groupOps` in the same batch, as the rules require. +class GroupRequests { + GroupRequests(this._db, this.uid, {Duration? timeout}) + : _timeout = timeout ?? const Duration(seconds: 30), + _counter = DailyCounter(_db, uid, 'groupOps'); + + final FirebaseFirestore _db; + final String uid; + final Duration _timeout; + final DailyCounter _counter; + + static String newId() => + b64(randomBytes(12)).replaceAll('+', '-').replaceAll('/', '_'); + + /// An account-level request: create, accept or decline. + Future account(String kind, Map fields) => + _run(_db.doc('users/$uid/groupOps/${newId()}'), kind, fields); + + /// A request about one group: invite, add, addDevice, remove, leave, + /// finalize or roles. + Future group(String gid, String kind, Map fields) => + _run(_db.doc('groups/$gid/ops/${newId()}'), kind, fields); + + /// Writes the request and waits for the server's answer. Throws + /// [GroupRefused] with the server's reason, or [TimeoutException] when the + /// outcome is not known yet (the request may still be handled). + Future _run( + DocumentReference> ref, + String kind, + Map fields, + ) async { + await _counter.withCount((add) async { + final batch = _db.batch() + ..set(ref, { + 'kind': kind, + 'by': uid, + 'at': FieldValue.serverTimestamp(), + ...fields, + }); + add(CounterWrite.batch(batch)); + await batch.commit(); + }); + final done = Completer(); + var seen = false; + final sub = ref.snapshots().listen( + (snap) { + // The last member leaving deletes the group with its requests: done. + if (!snap.exists && seen && !done.isCompleted) done.complete(); + if (snap.exists) seen = true; + final status = snap.data()?['status']; + if (status == 'done' && !done.isCompleted) done.complete(); + if (status == 'refused' && !done.isCompleted) { + done.completeError( + GroupRefused('${snap.data()?['reason'] ?? 'refused'}'), + ); + } + }, + onError: (Object e) { + if (!done.isCompleted) done.completeError(e); + }, + ); + try { + await done.future.timeout(_timeout); + } finally { + await sub.cancel(); + } + } +} diff --git a/lib/src/model/groups/group_service.dart b/lib/src/model/groups/group_service.dart new file mode 100644 index 0000000..7a028d7 --- /dev/null +++ b/lib/src/model/groups/group_service.dart @@ -0,0 +1,1326 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:cryptography/cryptography.dart'; +import 'package:fireplace/src/crypto/codec.dart'; +import 'package:fireplace/src/crypto/group_crypto.dart'; +import 'package:fireplace/src/db/group_record_file.dart'; +import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/db/secret_store.dart'; +import 'package:fireplace/src/db/message_mutation.dart'; +import 'package:fireplace/src/model/chat/message_limits.dart'; +import 'package:fireplace/src/model/chat/message_payload.dart'; +import 'package:fireplace/src/model/chat/send_allowance.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/model/groups/group_requests.dart'; +import 'package:fireplace/src/model/keys/key_service.dart'; +import 'package:flutter/foundation.dart'; + +/// Group chats for one account on this device (mobile decision 0050, +/// `docs/protocol/group-chats-v1.md`). +/// +/// All MLS work runs one call at a time. Each change from the core is checked, +/// saved (MLS records and this service's cursors together, atomically) and +/// only then accepted, so a crash loses nothing and repeats nothing. +/// +/// Commits are applied strictly in epoch order. A Commit is checked against +/// the group's rules before it is kept: a rename or role change only from an +/// administrator; a self-update adds and removes nobody; members are removed +/// only by administrators or once the server has fenced them. +/// +/// An invitation does the MLS Add: the inviter's device sends the +/// Commit and the Welcome with the request, so the invited person joins from +/// the Welcome they already have, with no other phone involved. +class GroupService { + GroupService({ + required FirebaseFirestore db, + required this.uid, + required this._device, + required this._secrets, + required this._historyDir, + required this._messages, + SendAllowance? allowance, + Duration? requestTimeout, + DateTime Function()? clock, + this._colourCatchUp = const Duration(seconds: 5), + }) : _db = db, + _allowance = allowance ?? SendAllowance(), + _now = clock ?? DateTime.now, + _requests = GroupRequests(db, uid, timeout: requestTimeout); + + final FirebaseFirestore _db; + final String uid; + final LocalDevice _device; + final SecretStore _secrets; + final Directory _historyDir; + final LocalMessageStore _messages; + final SendAllowance _allowance; + final DateTime Function() _now; + final GroupRequests _requests; + final Duration _colourCatchUp; + + late GroupRecordFile _file; + late GroupCrypto _crypto; + Future _tail = Future.value(); + bool _closed = false; + + final _groups = {}; + final _groupsCtrl = StreamController>.broadcast(); + var _invitations = []; + final _invitesCtrl = StreamController>.broadcast(); + final _colors = {}; + final _colorsCtrl = StreamController.broadcast(); + final _subs = >[]; + final _groupSubs = >>{}; + final _rosters = >{}; + // Events that arrived ahead of this device's epoch, waiting for Commits. + final _waiting = + >>>{}; + final _pending = {}; // groups with an own Commit awaiting its slot + // Joined from the Welcome here, while the server's answer is on its way. + final _joining = {}; + final _rosterCtrl = StreamController.broadcast(); + // What a member said before going ("left"), or an administrator before + // removing them: tells a removal's line which words to use. + final _departures = >{}; + final _removalLines = >{}; + // Groups whose members have been asked for their colours since the app started. + final _askedColours = {}; + + String get deviceId => _device.keys.deviceId; + + /// The groups this device is in, as MLS authenticated them. + Map get groups => Map.unmodifiable(_groups); + Stream> watchGroups() async* { + yield groups; + yield* _groupsCtrl.stream; + } + + List get invitations => _invitations; + Stream> watchInvitations() async* { + yield _invitations; + yield* _invitesCtrl.stream; + } + + /// A group's history on this device, newest last. + Stream> watchMessages(String gid) => + _messages.watch(groupChatId(gid)); + + MemberColors colorsOf(String gid) => + Map.unmodifiable(_colors[gid] ?? const {}); + Stream get colorChanges => _colorsCtrl.stream; + + /// People invited who have not answered yet, and who invited each. They are + /// in the MLS group already; the server's roster says they have not joined. + Map invitedTo(String gid) { + final invited = _rosters[gid]?['invited']; + return { + if (invited is Map) + for (final e in invited.entries) + '${e.key}': '${(e.value as Map?)?['by'] ?? ''}', + }; + } + + /// Fires with a group's id when the server's roster for it changes. + Stream get rosterChanges => _rosterCtrl.stream; + + /// True when this device joined a group that already existed, so its + /// history here starts after the group began. + bool joinedLater(String gid) => _file.app('newcomer:$gid') != null; + + // ---------------------------------------------------------------- start + + Future start() async { + _file = await GroupRecordFile.open( + historyDir: _historyDir, + secrets: _secrets, + uid: uid, + ); + _crypto = GroupCrypto.open(_file.blob()); + if (_file.isEmpty) { + final hash = await Sha256().hash(_device.bundle.cert); + await _change({ + 'op': 'device', + 'identity': jsonEncode(_device.identity.toJson()), + 'uid': uid, + 'device': deviceId, + 'deviceCertHash': b64(hash.bytes), + 'generation': 1, + }); + } + await _loadColors(); + for (final gid + in (_crypto.call({'op': 'groups'})['groups'] as List).cast()) { + final info = _info(gid); + if (info != null && info.active) _groups[gid] = info; + // An own Commit saved before a restart still waits for its epoch's slot. + try { + if (_crypto.call({'op': 'info', 'gid': gid})['pending'] == true) { + _pending.add(gid); + } + } on GroupCryptoException { + // Unknown group. + } + } + _emitGroups(); + await _replenishPackages(); + _subs.add( + _db + .collection('users/$uid/groupRefs') + .snapshots() + .listen((s) => _serial(() => _onRefs(s)), onError: (_) {}), + ); + _subs.add( + _db.collection('users/$uid/groupInvites').snapshots().listen((s) { + _invitations = [ + for (final d in s.docs) + GroupInvitation( + gid: d.id, + inviter: '${d.data()['by']}', + at: (d.data()['at'] as Timestamp?)?.toDate() ?? _now(), + ), + ]; + _invitesCtrl.add(_invitations); + }, onError: (_) {}), + ); + } + + Future close() async { + _closed = true; + for (final s in [..._subs, for (final l in _groupSubs.values) ...l]) { + await s.cancel(); + } + await _tail.catchError((Object _) {}); + _crypto.close(); + await _groupsCtrl.close(); + await _invitesCtrl.close(); + await _colorsCtrl.close(); + await _rosterCtrl.close(); + } + + // ---------------------------------------------------------------- the core + + /// Runs [f] after every earlier MLS call has finished. + Future _serial(Future Function() f) { + final run = _tail.then((_) => f()); + _tail = run.then((_) {}, onError: (Object _) {}); + return run; + } + + /// One changing core call: [check] may refuse the result (then nothing is + /// kept); otherwise the changes and [extra] app records are saved, then the + /// core accepts. + Future> _change( + Map request, { + bool Function(Map result)? check, + Map?> extra = const {}, + Future Function(Map result)? beforeSave, + }) async { + final result = _crypto.call(request); + try { + if (check != null && !check(result)) { + throw GroupCryptoException(2); + } + await beforeSave?.call(result); + _file.apply(result['changes']); + _file.setApp(extra); + await _file.save(); + } catch (_) { + _crypto.rollback(); + _file = await GroupRecordFile.open( + historyDir: _historyDir, + secrets: _secrets, + uid: uid, + ); + rethrow; + } + _crypto.accept(); + return result; + } + + GroupInfo? _info(String gid) { + try { + return GroupInfo.fromCore(_crypto.call({'op': 'info', 'gid': gid})); + } on GroupCryptoException { + return null; + } + } + + void _emitGroups() { + if (!_groupsCtrl.isClosed) _groupsCtrl.add(groups); + } + + /// Keeps ten key packages published, so admins can add this device. + Future _replenishPackages() => _serial(() async { + final col = _db.collection('users/$uid/devices/$deviceId/mlsPackages'); + final have = {for (final d in (await col.get()).docs) d.id}; + final missing = [ + for (var i = 0; i < GroupLimits.keyPackagesPerDevice; i++) + if (!have.contains('$i')) '$i', + ]; + if (missing.isEmpty) return; + final result = await _change({ + 'op': 'key_packages', + 'count': missing.length, + }); + final batch = _db.batch(); + final packages = (result['packages'] as List).cast(); + for (var i = 0; i < missing.length; i++) { + batch.set(col.doc(missing[i]), { + 'kp': Blob(unb64(packages[i]['keyPackage'] as String)), + 'createdAt': FieldValue.serverTimestamp(), + }); + } + await batch.commit(); + }); + + // ---------------------------------------------------------------- groups + + Future _onRefs(QuerySnapshot> refs) async { + final listed = {for (final d in refs.docs) d.id: d.data()}; + for (final entry in listed.entries) { + final gid = entry.key; + _joining.remove(gid); + // Joined on another of this account's devices, or before a reinstall. + if (!_groups.containsKey(gid) && await _joinFromWelcome(gid)) { + _askedColours.add(gid); + unawaited(_sendProfile(gid, hello: true)); + } + if (_groups.containsKey(gid)) { + await _noteSince(gid, entry.value['joinedAt']); + if (!_groupSubs.containsKey(gid)) _watch(gid); + } + } + // Removed by the server (left, removed, fenced): stop and forget the + // group's keys. Its history stays on this device. + for (final gid in [..._groups.keys]) { + if (!listed.containsKey(gid) && !_joining.contains(gid)) await _drop(gid); + } + } + + /// Joins from the Welcome the invitation left for this account. The Welcome + /// is from the epoch of the invitation, which may be long past: the Commit + /// listener then brings this device up to date. + Future _joinFromWelcome(String gid) async { + final welcomes = await _db + .collection('groups/$gid/welcomes') + .where('uid', isEqualTo: uid) + .get(); + for (final w in welcomes.docs) { + final ct = w.data()['ct']; + if (ct is! Blob) continue; + try { + // A Welcome for another of this account's devices, or an invitation + // that has since ended, is refused; try the next. + final result = await _change( + {'op': 'join', 'welcome': b64(ct.bytes)}, + extra: { + 'newcomer:$gid': [1], + }, + ); + final info = GroupInfo.fromCore(result); + if (info.gid != gid) continue; + _groups[gid] = info; + _emitGroups(); + return true; + } on GroupCryptoException { + continue; + } + } + return false; + } + + /// When the server put this account in the roster. Messages from before it + /// are not shown (0050: a newcomer sees no earlier history), though an + /// invited device holds the keys from the moment of the invitation. + Timestamp? _since(String gid) { + final raw = _file.app('since:$gid'); + final micros = raw == null ? null : int.tryParse(utf8.decode(raw)); + return micros == null ? null : Timestamp.fromMicrosecondsSinceEpoch(micros); + } + + Future _noteSince(String gid, Object? joinedAt) async { + if (joinedAt is! Timestamp || _file.app('since:$gid') != null) return; + _file.setApp({ + 'since:$gid': utf8.encode('${joinedAt.microsecondsSinceEpoch}'), + }); + await _file.save(); + } + + Future _drop(String gid) async { + for (final s + in _groupSubs.remove(gid) ?? const >[]) { + await s.cancel(); + } + _groups.remove(gid); + _rosters.remove(gid); + _departures.remove(gid); + _removalLines.remove(gid); + try { + await _change( + {'op': 'forget', 'gid': gid}, + extra: {'since:$gid': null, 'newcomer:$gid': null}, + ); + } on GroupCryptoException { + // Already forgotten. + } + _emitGroups(); + } + + void _watch(String gid) { + final subs = >[]; + subs.add( + _db.doc('groups/$gid').snapshots().listen((s) { + final data = s.data(); + if (data == null) return; + _rosters[gid] = data; + if (!_rosterCtrl.isClosed) _rosterCtrl.add(gid); + unawaited(_serial(() => _maintain(gid, data))); + }, onError: (_) {}), + ); + subs.add( + _db + .collection('groups/$gid/commits') + .where('e', isGreaterThanOrEqualTo: _groups[gid]?.epoch ?? 0) + .snapshots() + .listen((s) => _serial(() => _onCommits(gid, s)), onError: (_) {}), + ); + final cursor = _cursor(gid); + final since = _since(gid); + var events = _db.collection('groups/$gid/events').orderBy('at'); + if (cursor != null) { + events = events.startAfter([cursor]); + } else if (since != null) { + // A newcomer reads nothing from before they joined. + events = events.startAt([since]); + } + subs.add( + events.snapshots().listen( + (s) => _serial(() => _onEvents(gid, s.docs)), + onError: (_) {}, + ), + ); + _groupSubs[gid] = subs; + } + + static String _slot(int epoch) => '$epoch'.padLeft(12, '0'); + + Timestamp? _cursor(String gid) { + final raw = _file.app('cursor:$gid'); + if (raw == null) return null; + final micros = int.tryParse(utf8.decode(raw)); + return micros == null ? null : Timestamp.fromMicrosecondsSinceEpoch(micros); + } + + Map?> _cursorAt(String gid, Object? at) => at is Timestamp + ? {'cursor:$gid': utf8.encode('${at.microsecondsSinceEpoch}')} + : const {}; + + // ---------------------------------------------------------------- commits + + Future _onCommits( + String gid, + QuerySnapshot> snap, + ) async { + // In epoch order (sorted here; the query only skips old Commits). + final docs = [...snap.docs] + ..sort( + (a, b) => ((a.data()['e'] as num?) ?? 0).compareTo( + (b.data()['e'] as num?) ?? 0, + ), + ); + for (final doc in docs) { + final info = _groups[gid]; + if (info == null || _closed) return; + final epoch = (doc.data()['e'] as num?)?.toInt(); + if (doc.id != _slot(epoch ?? -1)) continue; // not a slot document + if (epoch == null || epoch < info.epoch) continue; + if (epoch > info.epoch) return; // a gap: wait for the missing Commit + await _applyCommit(gid, doc.data()); + } + } + + Future _applyCommit(String gid, Map doc) async { + final before = _groups[gid]!; + final mine = doc['by'] == uid && doc['device'] == deviceId; + if (_pending.contains(gid)) { + _pending.remove(gid); + if (mine) { + final result = await _change({'op': 'merge_pending', 'gid': gid}); + await _afterCommit(gid, result, before: before, by: uid, at: doc['at']); + return; + } + // Another Commit won this epoch. + await _change({'op': 'discard_pending', 'gid': gid}); + } + final ct = doc['ct']; + if (ct is! Blob) return; + final kind = doc['kind']; + if (kind == 'membership') { + // A removal by someone who is not an administrator is allowed only for + // devices the server has fenced. The roster held here may be older than + // this Commit, so read it again (one read per membership change). + try { + final fresh = (await _db.doc('groups/$gid').get()).data(); + if (fresh != null) _rosters[gid] = fresh; + } on FirebaseException { + // Offline: check against the roster already here. + } + } + final roster = + _rosters[gid]?['members'] as Map? ?? const {}; + try { + final result = await _change({ + 'op': 'process', + 'gid': gid, + 'message': b64(ct.bytes), + }, check: (r) => _commitAllowed(before, r, kind, roster, doc['by'])); + await _afterCommit( + gid, + result, + before: before, + by: '${doc['by']}', + at: doc['at'], + ); + } on GroupCryptoException { + // An invalid or refused Commit changes nothing here; the group waits for + // a valid one (proposal, "Event ordering"). + } + } + + bool _commitAllowed( + GroupInfo before, + Map r, + Object? kind, + Map roster, + Object? by, + ) { + if (r['kind'] != 'commit') return false; + final committer = (r['committer'] as Map)['uid'] as String; + if (committer != by) return false; + final added = (r['added'] as List).cast(); + final removed = (r['removed'] as List).cast(); + final admin = before.isAdmin(committer); + final admins = (r['admins'] as List).cast().toSet(); + final wereAdmins = { + for (final m in before.members) + if (m.admin) m.uid, + }; + final metaChanged = + r['name'] != before.name || + admins.length != wereAdmins.length || + !admins.containsAll(wereAdmins); + if (metaChanged && !admin) return false; + if (kind == 'update' || kind == 'meta') { + return added.isEmpty && removed.isEmpty; + } + // Any member may add someone by inviting them (owner's choice). The server + // allows it only for a contact of the inviter. An Add is not checked + // against the roster: by the time a phone that was offline sees it, the + // person may have declined, and refusing would leave this phone stuck. + const addsOk = true; + final removesOk = removed.every((m) { + if (admin) return true; + // Fenced by the server: no longer in its roster for that device. + final member = roster[m['uid']] as Map?; + final devices = (member?['devices'] as List?)?.cast() ?? const []; + return !devices.contains(m['device']); + }); + return addsOk && removesOk; + } + + Future _afterCommit( + String gid, + Map result, { + required GroupInfo before, + required String by, + Object? at, + }) async { + final info = GroupInfo.fromCore(result); + await _writeNotices(gid, before, info, by, at); + if (!info.active) { + unawaited(_serial(() => _drop(gid))); + return; + } + _groups[gid] = info; + _emitGroups(); + final waiting = _waiting.remove(gid); + if (waiting != null) unawaited(_serial(() => _onEvents(gid, waiting))); + } + + /// Writes the lines for one Commit ("alice added bob_jones") into the + /// history, from the MLS state before and after it. The Commit's author is + /// the one MLS authenticated. + Future _writeNotices( + String gid, + GroupInfo before, + GroupInfo after, + String by, + Object? at, + ) async { + final was = {for (final m in before.members) m.uid}; + final now = {for (final m in after.members) m.uid}; + final wasAdmin = { + for (final m in before.members) + if (m.admin) m.uid, + }; + final nowAdmin = { + for (final m in after.members) + if (m.admin) m.uid, + }; + final notices = [ + for (final who in now.difference(was)) + GroupNotice(GroupNoticeKind.added, by: by, who: who), + for (final who in was.difference(now)) + _departures[gid]?.remove(who) ?? + GroupNotice(GroupNoticeKind.gone, who: who), + for (final who in nowAdmin.difference(wasAdmin)) + if (was.contains(who)) + GroupNotice(GroupNoticeKind.madeAdmin, by: by, who: who), + for (final who in wasAdmin.difference(nowAdmin)) + if (now.contains(who)) + GroupNotice(GroupNoticeKind.removedAdmin, by: by, who: who), + if (after.name != before.name) + GroupNotice(GroupNoticeKind.renamed, by: by), + ]; + final sentAt = at is Timestamp ? at.toDate() : _now(); + for (var i = 0; i < notices.length; i++) { + final line = LocalMessage( + id: 'notice-${_slot(before.epoch)}-$i', + chatId: groupChatId(gid), + senderUid: by, + senderDevice: '', + outgoing: false, + sentAt: sentAt, + body: notices[i].encode(), + status: MessageStatus.notice, + ); + if (notices[i].kind == GroupNoticeKind.gone) { + (_removalLines[gid] ??= {})[notices[i].who!] = line; + } + await _messages.add(line); + } + } + + /// A member said they are leaving, or an administrator that they are + /// removing someone. Both are encrypted group messages, so MLS vouches for + /// who said it. The removal's line then says "left" or "removed". + Future _onDeparture( + String gid, + String sender, + Map json, + ) async { + final GroupNotice notice; + final String who; + if (json['kind'] == 'left') { + who = sender; + notice = GroupNotice(GroupNoticeKind.left, who: sender); + } else if (json['kind'] == 'removing' && + json['uid'] is String && + (_groups[gid]?.isAdmin(sender) ?? false)) { + who = json['uid'] as String; + notice = GroupNotice(GroupNoticeKind.removed, by: sender, who: who); + } else { + return; + } + // The removal may already be in the history, worded without a reason. + final line = _removalLines[gid]?.remove(who); + if (line != null) { + await _messages.add(line.copyWith(body: notice.encode())); + } else { + (_departures[gid] ??= {})[who] = notice; + } + } + + /// Work the group needs from any member: ending an invitation nobody + /// answered in seven days, and finalising a removal the server fenced. + Future _maintain(String gid, Map roster) async { + final info = _groups[gid]; + if (info == null || _pending.contains(gid)) return; + _catchUpColours(gid); + final fence = (roster['fence'] as List?)?.cast() ?? const []; + if (fence.isNotEmpty) { + final members = + (_crypto.call({'op': 'info', 'gid': gid})['members'] as List) + .cast(); + final leaves = [ + for (final m in members) + for (final f in fence) + if (f['uid'] == m['uid'] && + ((f['devices'] as List?) ?? const []).contains(m['device'])) + m['leaf'], + ]; + if (leaves.isEmpty) return; + await _ownCommit( + gid, + {'op': 'commit_remove', 'gid': gid, 'leaves': leaves}, + (r) => _requests.group(gid, 'finalize', { + 'epoch': info.epoch, + 'device': deviceId, + 'commit': Blob(unb64(r['commit'] as String)), + }), + ); + return; + } + // The server has no scheduler, so the first member's phone to notice an + // invitation past its seven days asks the server to end it. The server + // then fences those devices and the next roster change finalises. + final invited = roster['invited']; + if (invited is! Map) return; + for (final e in invited.entries) { + final expireAt = (e.value as Map?)?['expireAt']; + if (expireAt is Timestamp && expireAt.toDate().isBefore(_now())) { + try { + await _requests.group(gid, 'expire', {'uid': '${e.key}'}); + } catch (_) { + // Another member's phone got there first, or it will be tried again. + } + return; + } + } + } + + /// Prepares this device's Commit, saves it as pending and publishes it. + /// Once the server has taken it, it holds its epoch's slot, so it is merged + /// here at once; the next change can then follow without waiting for the + /// Commit listener. A refusal discards it, and with [strict] is passed on to + /// the caller to show. An unknown outcome leaves it for the listener, which + /// merges it if it won its epoch and discards it otherwise. + Future _ownCommit( + String gid, + Map request, + Future Function(Map result) publish, { + bool strict = false, + }) async { + final before = _groups[gid]!; + final result = await _change(request); + _pending.add(gid); + try { + await publish(result); + } on GroupRefused { + await _discardPending(gid); + if (strict) rethrow; + return; + } on FirebaseException { + await _discardPending(gid); + if (strict) rethrow; + return; + } on TimeoutException { + // Unknown: the Commit slot decides when it appears. + if (strict) rethrow; + return; + } + if (_pending.remove(gid)) { + final merged = await _change({'op': 'merge_pending', 'gid': gid}); + await _afterCommit(gid, merged, before: before, by: uid); + } + } + + Future _discardPending(String gid) async { + if (_pending.remove(gid)) { + await _change({'op': 'discard_pending', 'gid': gid}); + } + } + + // ---------------------------------------------------------------- events + + Future _onEvents( + String gid, + List>> docs, + ) async { + for (final doc in docs) { + final info = _groups[gid]; + if (info == null || _closed) return; + final data = doc.data(); + final epoch = (data['epoch'] as num?)?.toInt() ?? -1; + if (epoch > info.epoch) { + (_waiting[gid] ??= []).add(doc); + continue; + } + // From before this account joined: not shown, even where the keys allow. + final since = _since(gid); + final at = data['at']; + if (since != null && at is Timestamp && at.compareTo(since) < 0) continue; + if (await _messages.has(groupChatId(gid), doc.id)) continue; + // This device's own messages are saved when sent; MLS cannot decrypt them. + if (data['device'] == deviceId) continue; + final ct = data['ct']; + if (ct is! Blob) continue; + try { + await _change( + {'op': 'process', 'gid': gid, 'message': b64(ct.bytes)}, + // The authenticated sender and id must be what the server says. + check: (r) => + r['kind'] == 'application' && + (r['sender'] as Map)['uid'] == data['by'] && + (r['sender'] as Map)['device'] == data['device'] && + utf8.decode(unb64(r['aad'] as String)) == doc.id, + extra: _cursorAt(gid, data['at']), + beforeSave: (r) => + _store(gid, doc.id, data, unb64(r['plain'] as String)), + ); + } on GroupCryptoException { + // Undecryptable (too old, or not for this device): skip it. + } + } + } + + Future _store( + String gid, + String id, + Map data, + List plain, + ) async { + final sender = data['by'] as String; + try { + final json = jsonDecode(utf8.decode(plain)); + if (json is Map && json['type'] == 'profile' && json['color'] is String) { + (_colors[gid] ??= {})[sender] = json['color'] as String; + await _saveColors(); + if (!_colorsCtrl.isClosed) _colorsCtrl.add(gid); + // Someone has just joined and has nobody's colour yet: send them this + // account's (one small message per member per newcomer, at most 15). + if (json['hello'] == true && sender != uid) { + unawaited(_sendProfile(gid)); + } + return; + } + if (json is Map && json['type'] == 'notice') { + await _onDeparture(gid, sender, json); + return; + } + } catch (_) { + // Not a profile message. + } + final decoded = decodeMessagePayload(plain); + // Edits, reactions and deletes are stored like 1:1 ones: the history's + // projection applies an edit or delete only from the message's author. + await _messages.add( + LocalMessage( + id: id, + chatId: groupChatId(gid), + senderUid: sender, + senderDevice: data['device'] as String, + outgoing: sender == uid, + sentAt: (data['at'] as Timestamp?)?.toDate() ?? _now(), + body: decoded.body, + status: decoded.status, + quote: decoded.quote, + mutation: decoded.mutation, + ), + ); + } + + // ---------------------------------------------------------------- sending + + /// Edits one of this account's own messages, for everyone (up to 10 times + /// within 24 hours, as in 1:1 chats). + Future edit(String gid, String targetId, String body) async { + final target = await _own(gid, targetId); + final age = _now().difference(target.sentAt); + if (age.isNegative || + age > const Duration(hours: 24) || + target.editClock >= 10) { + throw GroupActionError( + 'You can edit your messages up to 10 times within 24 hours.', + ); + } + if (body.trim() == target.body) { + throw GroupActionError('Make a change before saving.'); + } + if (messageTooLong(body)) throw GroupActionError(messageLimitError); + await _control( + gid, + MessageMutation( + kind: MessageMutationKind.edit, + targetId: targetId, + body: body.trim(), + revision: target.editClock + 1, + ), + ); + } + + /// Deletes one of this account's own messages for everyone. + Future deleteForEveryone(String gid, String targetId) async { + await _own(gid, targetId); + await _control( + gid, + MessageMutation(kind: MessageMutationKind.delete, targetId: targetId), + ); + } + + /// Adds, changes or (the same emoji again) removes this account's reaction. + Future react(String gid, String targetId, String emoji) async { + final target = await _messages.get(groupChatId(gid), targetId); + if (target == null || !target.visible) { + throw GroupActionError('That message is no longer here.'); + } + final previous = target.reactions[uid]; + await _control( + gid, + MessageMutation( + kind: MessageMutationKind.reaction, + targetId: targetId, + emoji: emoji, + operation: previous?.emoji == emoji + ? ReactionOperation.clear + : ReactionOperation.set, + revision: (target.reactionClocks[uid] ?? previous?.revision ?? 0) + 1, + ), + ); + } + + /// Removes a message from this phone only. + Future deleteLocally(String gid, String messageId) => + _messages.add(LocalMessage.tombstone(groupChatId(gid), messageId)); + + Future _own(String gid, String targetId) async { + final target = await _messages.get(groupChatId(gid), targetId); + if (target == null || target.senderUid != uid || target.remotelyDeleted) { + throw GroupActionError('You can only change your own messages.'); + } + return target; + } + + Future _control(String gid, MessageMutation mutation) { + final checked = MessageMutation.fromJson(mutation.toJson()); + return _publish( + gid, + encodeMessagePayload(text: '', now: _now(), mutation: checked), + mutation: checked, + ); + } + + /// Encrypts and publishes one text message. The message is saved first + /// (unconfirmed); a definite refusal removes it, an unknown outcome keeps + /// it unconfirmed, and it is never resent automatically. + Future send(String gid, String text) { + if (messageTooLong(text)) throw GroupActionError(messageLimitError); + return _publish( + gid, + encodeMessagePayload(text: text, now: _now()), + text: text, + ); + } + + /// This account's primary colour name, kept current by the app layer. + String ownColor = 'ember'; + + /// Shares this account's colour inside the group. With [hello], sent on + /// joining, every member answers with theirs, so a newcomer has them all. + Future _sendProfile(String gid, {bool hello = false}) async { + try { + await _publish( + gid, + utf8.encode( + jsonEncode({ + 'v': 4, + 'type': 'profile', + 'color': ownColor, + if (hello) 'hello': true, + }), + ), + ); + } catch (_) { + // Shared again with the next change; members show Stone meanwhile. + } + } + + /// Asks a group's members for their colours when this phone lacks one: a + /// group from before colours were answered, or a colour sent while this + /// phone could not read it. Once per group each time the app starts, and + /// only after a pause, so a newcomer's own `hello` usually settles it first. + /// It costs one message here and one from each member (at most 16). + void _catchUpColours(String gid) { + if (_askedColours.contains(gid)) return; + bool missing() { + final members = _rosters[gid]?['members']; + final have = _colors[gid] ?? const {}; + return members is Map && + members.keys.any((u) => u != uid && !have.containsKey(u)); + } + + if (!missing()) return; + _askedColours.add(gid); + unawaited( + Future.delayed(_colourCatchUp, () async { + if (_closed || !_groups.containsKey(gid) || !missing()) return; + await _sendProfile(gid, hello: true); + }), + ); + } + + /// Says, inside the group, why someone is about to go. Best effort: without + /// it the line reads "is no longer in the group". + Future _sendNotice(String gid, Map notice) async { + try { + await _publish( + gid, + utf8.encode(jsonEncode({'v': 4, 'type': 'notice', ...notice})), + ); + } catch (_) { + // The removal still happens. + } + } + + /// Shares this account's colour with every group (after a change in Settings). + Future shareColor() async { + for (final gid in _groups.keys) { + await _sendProfile(gid); + } + } + + Future _publish( + String gid, + List payload, { + String? text, + MessageMutation? mutation, + }) async { + final n = _now().microsecondsSinceEpoch; + final id = '$deviceId-$n'; + final chatId = groupChatId(gid); + final result = await _serial(() async { + if (!_groups.containsKey(gid)) throw StateError('Not in this group.'); + return _change({ + 'op': 'encrypt', + 'gid': gid, + 'plain': b64(payload), + 'aad': b64(utf8.encode(id)), + }); + }); + final sentAt = _now(); + LocalMessage local(MessageStatus status) => LocalMessage( + id: id, + chatId: chatId, + senderUid: uid, + senderDevice: deviceId, + outgoing: true, + sentAt: sentAt, + body: text ?? '', + status: status, + ); + if (text != null) await _messages.add(local(MessageStatus.unconfirmed)); + final ref = _db.doc('groups/$gid/events/$id'); + final limit = _db.doc('users/$uid/limits/send'); + final doc = { + 'by': uid, + 'device': deviceId, + 'n': n, + 'epoch': result['epoch'], + 'ct': Blob(unb64(result['ciphertext'] as String)), + 'at': FieldValue.serverTimestamp(), + 'expireAt': Timestamp.fromDate(sentAt.add(const Duration(days: 30))), + }; + try { + await publishWithClock( + allowance: _allowance, + retryable: (e) => + e is FirebaseException && e.code == 'permission-denied', + pauses: const [Duration(milliseconds: 300), Duration(seconds: 1)], + publish: (clock) { + final at = FieldValue.serverTimestamp(); + final one = FieldValue.increment(1); + final batch = _db.batch()..set(ref, doc); + switch (clock) { + case SendClock.day: + batch.set(limit, { + 'at': at, + 'window': at, + 'sent': 1, + 'day': at, + 'today': 1, + }); + case SendClock.window: + batch.update(limit, { + 'at': at, + 'window': at, + 'sent': 1, + 'today': one, + }); + case SendClock.next: + batch.update(limit, {'at': at, 'sent': one, 'today': one}); + case SendClock.dayInWindow: + batch.update(limit, { + 'at': at, + 'day': at, + 'today': 1, + 'sent': one, + }); + } + return batch.commit().timeout(const Duration(seconds: 20)); + }, + ); + if (text != null) await _messages.add(local(MessageStatus.ok)); + if (mutation != null) { + await _messages.add( + LocalMessage( + id: id, + chatId: chatId, + senderUid: uid, + senderDevice: deviceId, + outgoing: true, + sentAt: sentAt, + body: '', + mutation: mutation, + ), + ); + } + } on FirebaseException { + // Refused in every form: nothing was published. + if (text != null) await _messages.remove(chatId, id); + rethrow; + } + } + + // ---------------------------------------------------------------- membership + + /// Creates a group and invites [members] (uids of accepted 1:1 contacts). + Future create(String name, List members) async { + final gid = b64(randomBytes(16)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', ''); + final result = await _serial( + () => _change({'op': 'create', 'gid': gid, 'name': name}), + ); + try { + await _requests.account('create', { + 'gid': gid, + 'devices': [deviceId], + 'epoch': result['epoch'] as int, + }); + } catch (_) { + await _serial(() => _change({'op': 'forget', 'gid': gid})); + rethrow; + } + _groups[gid] = GroupInfo.fromCore(result); + _emitGroups(); + // The server's reference may have arrived before the group was recorded + // here; start watching it now either way. + if (!_groupSubs.containsKey(gid)) _watch(gid); + unawaited(_sendProfile(gid)); + for (final m in members) { + await invite(gid, m).catchError((Object _) {}); + } + return gid; + } + + /// Invites [memberUid] and adds their devices to the MLS group in the same + /// request. This phone is online now, so it does the work now: the + /// invited person later joins from the Welcome without any other phone. + Future invite(String gid, String memberUid) => _serial(() async { + final info = _groups[gid]; + if (info == null) throw GroupActionError('You are not in this group.'); + // Up to four of their devices that have a key package to use. + final listed = await _db.collection('users/$memberUid/devices').get(); + final devices = []; + final packages = >[]; + final bytes = []; + for (final d in listed.docs) { + if (devices.length == 4) break; + if (d.data()['revokedAt'] != null) continue; + final p = await d.reference.collection('mlsPackages').limit(1).get(); + final kp = p.docs.firstOrNull?.data()['kp']; + if (kp is! Blob) continue; + devices.add(d.id); + packages.add({'device': d.id, 'slot': p.docs.first.id}); + bytes.add(b64(kp.bytes)); + } + if (devices.isEmpty) throw GroupRefused('packages'); + await _ownCommit( + gid, + {'op': 'commit_add', 'gid': gid, 'keyPackages': bytes}, + (r) => _requests.group(gid, 'invite', { + 'uid': memberUid, + 'epoch': info.epoch, + 'device': deviceId, + 'devices': devices, + 'commit': Blob(unb64(r['commit'] as String)), + 'welcome': Blob(unb64(r['welcome'] as String)), + 'packages': packages, + }), + strict: true, + ); + }); + + /// Joins a group this account was invited to. The Welcome is already here, + /// so the group opens at once; the server then moves the account into the + /// roster, which lets this phone read and send. + Future accept(String gid) async { + final joined = await _serial(() async { + if (_groups.containsKey(gid)) return true; + _joining.add(gid); + return _joinFromWelcome(gid); + }); + if (!joined) { + _joining.remove(gid); + throw GroupActionError( + 'This invitation is for your other phone. Open Fireplace there to join.', + ); + } + try { + await _requests.account('accept', {'gid': gid}); + } on GroupRefused { + _joining.remove(gid); + await _serial(() => _drop(gid)); + rethrow; + } + // The group stays exempt from the server's list until it appears there. + _askedColours.add(gid); + unawaited(_sendProfile(gid, hello: true)); + } + + Future decline(String gid) => + _requests.account('decline', {'gid': gid}); + + /// Leaving stops reading at once; a remaining member's Commit removes this + /// account's devices afterwards. + Future leave(String gid) async { + await _sendNotice(gid, {'kind': 'left'}); + await _requests.group(gid, 'leave', {}); + await _serial(() => _drop(gid)); + } + + /// Removes a member, or someone invited who has not answered. + Future remove(String gid, String memberUid) async { + if (!invitedTo(gid).containsKey(memberUid)) { + await _sendNotice(gid, {'kind': 'removing', 'uid': memberUid}); + // This phone does not read its own messages back. + (_departures[gid] ??= {})[memberUid] = GroupNotice( + GroupNoticeKind.removed, + by: uid, + who: memberUid, + ); + } + await _serial(() async { + final info = _groups[gid]!; + final members = + (_crypto.call({'op': 'info', 'gid': gid})['members'] as List) + .cast(); + final leaves = [ + for (final m in members) + if (m['uid'] == memberUid) m['leaf'], + ]; + await _ownCommit( + gid, + {'op': 'commit_remove', 'gid': gid, 'leaves': leaves}, + (r) => _requests.group(gid, 'remove', { + 'uid': memberUid, + 'epoch': info.epoch, + 'device': deviceId, + 'commit': Blob(unb64(r['commit'] as String)), + }), + strict: true, + ); + }); + } + + /// Makes a member an administrator, or stops them being one: an MLS Commit + /// carrying the new list, which every phone accepts from administrators + /// only, and the same list to the server for its roster. + Future setAdmin(String gid, String memberUid, {required bool admin}) => + _serial(() async { + final info = _groups[gid]; + if (info == null || !info.isAdmin(uid)) { + throw GroupActionError('Only administrators can do that.'); + } + final joined = { + for (final m in info.members) + if (!invitedTo(gid).containsKey(m.uid)) m.uid, + }; + // When nobody on the list is still here, everybody administers. + var admins = { + for (final m in info.members) + if (m.admin && joined.contains(m.uid)) m.uid, + }; + if (admins.isEmpty) admins = {...joined}; + if (!joined.contains(memberUid)) { + throw GroupActionError( + 'They can be an administrator once they have joined.', + ); + } + admin ? admins.add(memberUid) : admins.remove(memberUid); + if (admins.isEmpty) { + throw GroupActionError('A group needs at least one administrator.'); + } + final list = admins.toList()..sort(); + await _ownCommit( + gid, + {'op': 'commit_meta', 'gid': gid, 'admins': list}, + (r) => _requests.group(gid, 'roles', { + 'admins': list, + 'epoch': info.epoch, + 'device': deviceId, + 'commit': Blob(unb64(r['commit'] as String)), + }), + strict: true, + ); + }); + + /// Renames the group: a Commit any member's device can apply, accepted from + /// administrators only. + Future rename(String gid, String name) => _serial(() async { + final info = _groups[gid]!; + await _ownCommit(gid, {'op': 'commit_meta', 'gid': gid, 'name': name}, ( + r, + ) async { + final batch = _db.batch() + ..set(_db.doc('groups/$gid/commits/${_slot(info.epoch)}'), { + 'e': info.epoch, + 'by': uid, + 'device': deviceId, + 'kind': 'meta', + 'ct': Blob(unb64(r['commit'] as String)), + 'at': FieldValue.serverTimestamp(), + }) + ..update(_db.doc('groups/$gid'), {'epoch': info.epoch + 1}); + await batch.commit(); + }); + }); + + /// Publishes a rename or role Commit without asking whether this account + /// may. The app never does: tests use it to show other phones refuse it. + @visibleForTesting + Future commitMetaUnchecked( + String gid, { + String? name, + List? admins, + }) => _serial(() async { + final info = _groups[gid]!; + await _ownCommit( + gid, + {'op': 'commit_meta', 'gid': gid, 'name': ?name, 'admins': ?admins}, + (r) async { + final batch = _db.batch() + ..set(_db.doc('groups/$gid/commits/${_slot(info.epoch)}'), { + 'e': info.epoch, + 'by': uid, + 'device': deviceId, + 'kind': 'meta', + 'ct': Blob(unb64(r['commit'] as String)), + 'at': FieldValue.serverTimestamp(), + }) + ..update(_db.doc('groups/$gid'), {'epoch': info.epoch + 1}); + await batch.commit(); + }, + ); + }); + + // ---------------------------------------------------------------- colours + + Future _loadColors() async { + final raw = await _secrets.read('groupcolors:$uid'); + if (raw == null) return; + final json = jsonDecode(raw) as Map; + for (final e in json.entries) { + _colors['${e.key}'] = (e.value as Map).map( + (k, v) => MapEntry('$k', '$v'), + ); + } + } + + Future _saveColors() => + _secrets.write('groupcolors:$uid', jsonEncode(_colors)); +} diff --git a/lib/src/model/notifications/app_badge.dart b/lib/src/model/notifications/app_badge.dart index eb8aec1..0b44a62 100644 --- a/lib/src/model/notifications/app_badge.dart +++ b/lib/src/model/notifications/app_badge.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/model/notifications/chat_activity.dart'; import 'package:fireplace/src/model/notifications/system_notifications.dart'; @@ -19,6 +20,7 @@ final appBadgeProvider = Provider((ref) { final count = appBadgeCount( ref.read(appSessionProvider), ref.read(chatActivityProvider), + groups: ref.read(groupsBadgeProvider), ); if (count == null || count == shown) return; shown = count; @@ -27,15 +29,22 @@ final appBadgeProvider = Provider((ref) { } ref.listen(appSessionProvider, (_, _) => update()); + ref.listen(groupsBadgeProvider, (_, _) => update()); ref.listen(chatActivityProvider, (_, _) => update(), fireImmediately: true); }); /// The badge to show, or null to leave it as it is. While the session is /// starting (or failed) or history is still loading, the old badge stays: /// dropping it to zero at every launch would also clear unseen alerts. -int? appBadgeCount(AsyncValue session, ChatActivityState activity) { +/// +/// [groups] is the unread total of group chats that are not muted. +int? appBadgeCount( + AsyncValue session, + ChatActivityState activity, { + int groups = 0, +}) { if (session.isLoading || session.hasError) return null; if (session.value == null) return 0; // signed out if (activity.loading) return null; - return activity.badgeCount; + return activity.badgeCount + groups; } diff --git a/lib/src/model/search/message_search.dart b/lib/src/model/search/message_search.dart index 19d3fdd..8cbab2a 100644 --- a/lib/src/model/search/message_search.dart +++ b/lib/src/model/search/message_search.dart @@ -33,7 +33,10 @@ List searchMessages( for (final messages in history.values) { for (final m in messages) { - if (m.status == MessageStatus.undecryptable || m.remotelyDeleted) { + // A line about a group ("alice added bob") is not text anyone wrote. + if (m.status == MessageStatus.undecryptable || + m.status == MessageStatus.notice || + m.remotelyDeleted) { continue; } final text = displayMessage(m.body).plain; diff --git a/lib/src/view/chat/chat_composer.dart b/lib/src/view/chat/chat_composer.dart index 3a3e966..48186b6 100644 --- a/lib/src/view/chat/chat_composer.dart +++ b/lib/src/view/chat/chat_composer.dart @@ -20,7 +20,7 @@ class ChatComposer extends StatelessWidget { required this.identityHeld, required this.hasSession, required this.contactBusy, - required this.send, + this.send, required this.onUnblock, this.onInteraction, this.contextBar, @@ -43,7 +43,10 @@ class ChatComposer extends StatelessWidget { identityHeld, hasSession, contactBusy; - final SendController send; + + /// Sends the text when [submit] is not given. A group conversation always + /// gives [submit] and has no 1:1 send controller. + final SendController? send; final Future Function() onUnblock; final VoidCallback? onInteraction; @override @@ -78,9 +81,9 @@ class ChatComposer extends StatelessWidget { child: CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.enter, control: true): - submit ?? send.send, + submit ?? send!.send, const SingleActivator(LogicalKeyboardKey.enter, meta: true): - submit ?? send.send, + submit ?? send!.send, }, child: Container( padding: const EdgeInsets.fromLTRB(12, 6, 8, 6), @@ -178,7 +181,7 @@ class ChatComposer extends StatelessWidget { text.text.trim().isEmpty || !hasSession ? null - : submit ?? send.send, + : submit ?? send!.send, tooltip: editing ? 'Save edit' : 'Send message', icon: editing && editingBusy ? SizedBox.square( diff --git a/lib/src/view/chat/message_widgets.dart b/lib/src/view/chat/message_widgets.dart new file mode 100644 index 0000000..e1ba37b --- /dev/null +++ b/lib/src/view/chat/message_widgets.dart @@ -0,0 +1,6 @@ +/// The message widgets other conversation screens reuse: a group conversation +/// is drawn with the same bubble and day separator as a 1:1 chat. +library; + +export 'package:fireplace/src/view/chat/widgets/day_separator.dart'; +export 'package:fireplace/src/view/chat/widgets/message_bubble.dart'; diff --git a/lib/src/view/chat/widgets/message_bubble.dart b/lib/src/view/chat/widgets/message_bubble.dart index df725f4..9627356 100644 --- a/lib/src/view/chat/widgets/message_bubble.dart +++ b/lib/src/view/chat/widgets/message_bubble.dart @@ -16,6 +16,7 @@ import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/model/notifications/chat_activity.dart'; import 'package:fireplace/src/model/chat/pending_sends.dart'; import 'package:fireplace/src/model/chat/outgoing_sends.dart'; +import 'package:fireplace/src/styles/chat_colors.dart'; class MessageBubble extends ConsumerStatefulWidget { const MessageBubble({ @@ -30,9 +31,20 @@ class MessageBubble extends ConsumerStatefulWidget { this.onQuote, this.outgoingSend, this.onFailedTap, + this.incomingColor, + this.liveOf, }); final LocalMessage message; + /// The colour of a message from someone else, when it is not this account's + /// own choice: in a group each member's messages use that member's colour. + final ChatBubbleColor? incomingColor; + + /// The stored message as it is now, for the copy shown while its menu is + /// open. Set where the conversation is not a 1:1 chat (a group); null means + /// the 1:1 history and its checks. + final LocalMessage? Function(WidgetRef ref)? liveOf; + /// Set for a message that is on its way or was not sent. On the way it /// shows its time, like a message that has been sent. A failed one says so /// and opens its choices when tapped. @@ -100,6 +112,18 @@ class _MessageBubbleState extends ConsumerState { : null, preview: Consumer( builder: (context, previewRef, _) { + if (widget.liveOf case final liveOf?) { + final live = liveOf(previewRef); + if (!mounted || live == null || live.remotelyDeleted) { + return const SizedBox.shrink(); + } + return MessageBubble( + message: live, + senderName: widget.senderName, + preview: true, + incomingColor: widget.incomingColor, + ); + } final session = previewRef.watch(appSessionProvider).value; final summary = previewRef.watch( chatSummaryProvider(widget.message.chatId), @@ -175,7 +199,9 @@ class _MessageBubbleState extends ConsumerState { : widget.senderName!); final base = mine ? colors.outgoing.outgoing - : colors.incoming.incoming(Theme.of(context).brightness); + : (widget.incomingColor ?? colors.incoming).incoming( + Theme.of(context).brightness, + ); final quoted = message.quote != null && !message.remotelyDeleted; // With a quote the bubble's own padding shrinks to frame the panel, so // the text keeps its usual distance from the edge with this. diff --git a/lib/src/view/chat_list/chat_list_screen.dart b/lib/src/view/chat_list/chat_list_screen.dart index 6cf2ed8..b29b870 100644 --- a/lib/src/view/chat_list/chat_list_screen.dart +++ b/lib/src/view/chat_list/chat_list_screen.dart @@ -1,7 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/view/groups/group_chat_screen.dart'; +import 'package:fireplace/src/view/groups/group_tile.dart'; +import 'package:fireplace/src/view/groups/new_group_screen.dart'; import 'package:fireplace/src/model/chat/chat_preview.dart'; +import 'package:fireplace/src/model/chat/chat_summary.dart'; import 'package:fireplace/src/view/chat/chat_screen.dart'; import 'package:fireplace/src/styles/brand/logo.dart'; import 'package:fireplace/src/styles/brand/lockup.dart'; @@ -12,7 +18,7 @@ import 'package:fireplace/src/styles/design_tokens.dart'; import 'package:fireplace/src/view/search/message_search_results.dart'; import 'package:fireplace/src/widgets/app_bar.dart'; import 'package:fireplace/src/widgets/page.dart'; -import 'package:fireplace/src/view/chat_list/start_chat_dialog.dart'; +import 'package:fireplace/src/view/chat_list/new_chat_sheet.dart'; import 'package:fireplace/src/view/chat_list/widgets/chat_search_field.dart'; import 'package:fireplace/src/view/chat_list/widgets/chat_tile.dart'; import 'package:fireplace/src/view/chat_list/widgets/new_chat_icon.dart'; @@ -60,6 +66,8 @@ class _ChatListScreenState extends ConsumerState { @override Widget build(BuildContext context) { final chats = ref.watch(chatsProvider); + // Shares a new primary colour with your groups when it changes. + ref.watch(groupColorSyncProvider); return Scaffold( appBar: UiAppBar( context: context, @@ -167,6 +175,17 @@ class _ChatListScreenState extends ConsumerState { for (final c in live) if (c.isIncomingRequest(me)) c, ]; + // Group invitations wait in Message requests too. + final invitations = + ref.watch(groupInvitationsProvider).value ?? const []; + final requestCount = requests.length + invitations.length; + final groups = [ + for (final g + in (ref.watch(groupsProvider).value ?? const {}).values) + if (_query.isEmpty || + g.name.toLowerCase().contains(_query)) + g, + ]; // Newest first, by the newest message this phone knows of: a // message just sent or received moves its chat up at once. final epoch = DateTime.fromMillisecondsSinceEpoch(0); @@ -217,15 +236,13 @@ class _ChatListScreenState extends ConsumerState { key: const Key('globalSearchResults'), padding: const EdgeInsets.only(bottom: 96), children: [ - if (requests.isNotEmpty) + if (requestCount > 0) ListTile( key: const Key('requestsTile'), leading: const Icon( Icons.mark_email_unread_outlined, ), - title: Text( - 'Message requests (${requests.length})', - ), + title: Text('Message requests ($requestCount)'), subtitle: const Text( 'People who want to chat with you', ), @@ -253,11 +270,42 @@ class _ChatListScreenState extends ConsumerState { onOpen: () => _open(ChatScreen(chatId: chat.chatId)), ), + if (groups.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Text( + 'Groups', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + for (final g in groups) + GroupTile( + group: g, + onOpen: () => _open(GroupChatScreen(gid: g.gid)), + ), GlobalMessageResults(query: _query, onOpen: _open), ], ); } - if (sorted.isEmpty && requests.isEmpty) { + // Chats and groups together, newest activity first. + final groupActivity = { + for (final g in groups) + g.gid: + (ref + .watch(groupMessagesProvider(g.gid)) + .value + ?.lastOrNull + ?.sentAt) ?? + epoch, + }; + final entries = [...sorted, ...groups] + ..sort((a, b) { + DateTime at(Object e) => e is GroupInfo + ? groupActivity[e.gid]! + : activity[(e as ChatSummary).chatId]!; + return at(b).compareTo(at(a)); + }); + if (entries.isEmpty && requestCount == 0) { if (_query.isNotEmpty) { return UiEmptyState( title: emptyTitle, @@ -266,14 +314,14 @@ class _ChatListScreenState extends ConsumerState { } return _Empty(); } - final requestOffset = requests.isEmpty ? 0 : 1; + final requestOffset = requestCount == 0 ? 0 : 1; return ListView.separated( key: const Key('conversationList'), padding: const EdgeInsets.only(bottom: 96), itemCount: - sorted.length + + entries.length + requestOffset + - (sorted.isEmpty ? 1 : 0), + (entries.isEmpty ? 1 : 0), // A hairline from the text edge to the row's end. separatorBuilder: (_, _) => const Divider( height: 1 / 3, @@ -282,7 +330,7 @@ class _ChatListScreenState extends ConsumerState { endIndent: ChatTile.edge, ), itemBuilder: (_, i) { - if (requests.isNotEmpty && i == 0) { + if (requestCount > 0 && i == 0) { return ListTile( key: Key('requestsTile'), leading: CircleAvatar( @@ -294,7 +342,7 @@ class _ChatListScreenState extends ConsumerState { child: Icon(Icons.mark_email_unread_outlined), ), title: Text( - 'Message requests (${requests.length})', + 'Message requests ($requestCount)', style: TextStyle(fontWeight: FontWeight.w700), ), subtitle: Text('People who want to chat with you'), @@ -302,7 +350,7 @@ class _ChatListScreenState extends ConsumerState { onTap: () => _open(const RequestsScreen()), ); } - if (sorted.isEmpty) { + if (entries.isEmpty) { return _query.isEmpty ? const UiEmptyState( title: 'No conversations yet', @@ -313,7 +361,14 @@ class _ChatListScreenState extends ConsumerState { message: 'Try another username. Your message requests are shown above.', ); } - final chat = sorted[i - requestOffset]; + final entry = entries[i - requestOffset]; + if (entry is GroupInfo) { + return GroupTile( + group: entry, + onOpen: () => _open(GroupChatScreen(gid: entry.gid)), + ); + } + final chat = entry as ChatSummary; return ChatTile( chat: chat, onOpen: () => _open(ChatScreen(chatId: chat.chatId)), @@ -329,20 +384,22 @@ class _ChatListScreenState extends ConsumerState { ); } + /// New chat: a panel to pick someone you chat with, type a username, or + /// create a group. Future _newChat(BuildContext context, WidgetRef ref) async { if (_opening) return; - _opening = true; - try { - final chatId = await showDialog( - context: context, - builder: (_) => const StartChatDialog(), - ); - if (chatId == null || !context.mounted) return; - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => ChatScreen(chatId: chatId)), - ); - } finally { - _opening = false; + final choice = await NewChatSheet.show(context); + if (choice == null || !context.mounted) return; + switch (choice) { + case OpenChat(:final chatId): + await _open(ChatScreen(chatId: chatId)); + case CreateGroup(): + final gid = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NewGroupScreen()), + ); + if (gid != null && context.mounted) { + await _open(GroupChatScreen(gid: gid)); + } } } } diff --git a/lib/src/view/chat_list/chat_row.dart b/lib/src/view/chat_list/chat_row.dart new file mode 100644 index 0000000..385c323 --- /dev/null +++ b/lib/src/view/chat_list/chat_row.dart @@ -0,0 +1,5 @@ +/// The 1:1 row and its unread count, for other rows in the list (a group's) +/// that share its measurements. +library; + +export 'package:fireplace/src/view/chat_list/widgets/chat_tile.dart'; diff --git a/lib/src/view/chat_list/new_chat_sheet.dart b/lib/src/view/chat_list/new_chat_sheet.dart new file mode 100644 index 0000000..d72e7e5 --- /dev/null +++ b/lib/src/view/chat_list/new_chat_sheet.dart @@ -0,0 +1,295 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/model/account/auth_service.dart'; +import 'package:fireplace/src/model/chat/chat_service.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/widgets/avatar.dart'; + +/// What the person chose in [NewChatSheet]. +sealed class NewChatChoice { + const NewChatChoice(); +} + +/// Open this 1:1 chat (an existing one, or one just started). +class OpenChat extends NewChatChoice { + const OpenChat(this.chatId); + final String chatId; +} + +/// Go on to name a new group and pick its people. +class CreateGroup extends NewChatChoice { + const CreateGroup(); +} + +/// The panel behind the New chat button: a "To:" field, *Create group*, and +/// the people you already chat with. Typing filters the list; a username that +/// is not in it can be messaged directly. +class NewChatSheet extends ConsumerStatefulWidget { + const NewChatSheet({super.key}); + + static Future show(BuildContext context) => + showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + showDragHandle: false, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => const NewChatSheet(), + ); + + @override + ConsumerState createState() => _NewChatSheetState(); +} + +class _NewChatSheetState extends ConsumerState { + final _to = TextEditingController(); + bool _busy = false; + String? _error; + + @override + void dispose() { + _to.dispose(); + super.dispose(); + } + + String get _query => _to.text.trim().toLowerCase(); + + /// Starts a chat with a username typed in the To: field. + Future _start(String name) async { + if (_busy) return; + if (name.isEmpty) { + setState(() => _error = 'Enter a username.'); + return; + } + if (!AuthService.isValidUsername(name)) { + setState(() => _error = 'Use 3–20 letters, numbers or underscores.'); + return; + } + final session = ref.read(appSessionProvider).value; + if (session == null) { + setState(() => _error = 'Your account is not ready yet.'); + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + final id = await session.chat.startChat(name); + if (mounted) Navigator.pop(context, OpenChat(id)); + } on ChatException catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) setState(() => _error = 'Could not start chat. Try again.'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final t = FireplaceUiTokens.of(context); + final me = ref.watch(appSessionProvider).value?.uid ?? ''; + final blocked = ref.watch(blockedUidsProvider).value ?? const {}; + final groupsOn = ref.watch(groupsOnProvider); + final chats = + [ + for (final c + in ref.watch(chatsProvider).value ?? const []) + if (!c.isIncomingRequest(me) && !blocked.contains(c.peerUid)) c, + ]..sort( + (a, b) => (b.lastMessageAt ?? DateTime(0)).compareTo( + a.lastMessageAt ?? DateTime(0), + ), + ); + String? nameOf(ChatSummary c) => + ref.watch(peerUsernameProvider(c.peerUid)).value; + final shown = [ + for (final c in chats) + if (_query.isEmpty || + (nameOf(c)?.toLowerCase().contains(_query) ?? false)) + c, + ]; + final exact = chats + .where((c) => nameOf(c)?.toLowerCase() == _query) + .firstOrNull; + final typed = _to.text.trim(); + final canMessage = exact == null && AuthService.isValidUsername(typed); + return SizedBox( + height: MediaQuery.sizeOf(context).height, + child: Column( + children: [ + SizedBox( + height: 56, + child: Stack( + alignment: Alignment.center, + children: [ + Align( + alignment: Alignment.centerLeft, + child: TextButton( + key: const Key('cancelNewChat'), + style: TextButton.styleFrom(foregroundColor: t.text), + onPressed: _busy ? null : () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ), + Text( + 'New chat', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), + child: TextField( + key: const Key('peerUsername'), + controller: _to, + enabled: !_busy, + autocorrect: false, + enableSuggestions: false, + textInputAction: TextInputAction.go, + style: Theme.of(context).textTheme.titleMedium, + decoration: InputDecoration( + // Always shown, unlike prefixText, which needs focus. + prefixIcon: Padding( + padding: const EdgeInsets.only(right: 8), + child: Text( + 'To:', + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(color: t.secondaryText), + ), + ), + prefixIconConstraints: const BoxConstraints(), + contentPadding: const EdgeInsets.symmetric(vertical: 14), + hintText: 'Username', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + filled: false, + errorText: _error, + errorMaxLines: 3, + ), + onChanged: (_) => setState(() => _error = null), + // Reads the field now, not the text of the last build. + onSubmitted: (value) { + final name = value.trim(); + final match = chats + .where( + (c) => + ref + .read(peerUsernameProvider(c.peerUid)) + .value + ?.toLowerCase() == + name.toLowerCase(), + ) + .firstOrNull; + if (match != null) { + Navigator.pop(context, OpenChat(match.chatId)); + } else { + _start(name); + } + }, + ), + ), + Divider(height: 1, color: t.separator), + Expanded( + child: ListView( + key: const Key('newChatList'), + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, + children: [ + if (groupsOn && _query.isEmpty) + ListTile( + key: const Key('newGroup'), + leading: CircleAvatar( + radius: 22, + backgroundColor: t.panel, + foregroundColor: t.text, + child: const Icon(Icons.groups_outlined), + ), + title: const Text( + 'Create group', + style: TextStyle(fontWeight: FontWeight.w700), + ), + trailing: Icon(Icons.chevron_right, color: t.secondaryText), + onTap: () => Navigator.pop(context, const CreateGroup()), + ), + if (canMessage) + ListTile( + key: const Key('startChat'), + enabled: !_busy, + leading: CircleAvatar( + radius: 22, + backgroundColor: t.accent, + foregroundColor: Colors.white, + child: _busy + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.chat_bubble_outline_rounded), + ), + title: Text('Message @$typed'), + subtitle: Text( + _busy ? 'Starting…' : 'Send a message request', + ), + onTap: () => _start(typed), + ), + for (final c in shown) + _ContactRow( + chat: c, + onTap: _busy + ? null + : () => Navigator.pop(context, OpenChat(c.chatId)), + ), + if (shown.isEmpty && !canMessage && _query.isNotEmpty) + Padding( + padding: const EdgeInsets.all(24), + child: Text( + 'No one you chat with is called that.', + textAlign: TextAlign.center, + style: TextStyle(color: t.secondaryText), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +/// One person you already chat with. +class _ContactRow extends ConsumerWidget { + const _ContactRow({required this.chat, required this.onTap}); + final ChatSummary chat; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final name = ref.watch(peerUsernameProvider(chat.peerUid)).value ?? '…'; + return ListTile( + key: Key('contact_${chat.peerUid}'), + leading: PersonAvatar(name: name), + title: Text('@$name', maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: chat.accepted + ? null + : Text( + 'Request sent', + style: TextStyle( + color: FireplaceUiTokens.of(context).secondaryText, + ), + ), + onTap: onTap, + ); + } +} diff --git a/lib/src/view/chat_list/start_chat_dialog.dart b/lib/src/view/chat_list/start_chat_dialog.dart deleted file mode 100644 index 42cdbcd..0000000 --- a/lib/src/view/chat_list/start_chat_dialog.dart +++ /dev/null @@ -1,94 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fireplace/src/app/providers.dart'; -import 'package:fireplace/src/model/chat/chat_service.dart'; -import 'package:fireplace/src/model/account/auth_service.dart'; -import 'package:fireplace/src/widgets/dialog.dart'; - -class StartChatDialog extends ConsumerStatefulWidget { - const StartChatDialog({super.key}); - @override - ConsumerState createState() => _StartChatDialogState(); -} - -class _StartChatDialogState extends ConsumerState { - final _controller = TextEditingController(); - bool _busy = false; - String? _error; - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - Future _start() async { - if (_busy) return; - final name = _controller.text.trim(); - if (name.isEmpty) { - setState(() => _error = 'Enter a username.'); - return; - } - if (!AuthService.isValidUsername(name)) { - setState(() => _error = 'Use 3–20 letters, numbers or underscores.'); - return; - } - final session = ref.read(appSessionProvider).value; - if (session == null) { - setState(() => _error = 'Your account is not ready yet.'); - return; - } - setState(() { - _busy = true; - _error = null; - }); - try { - final id = await session.chat.startChat(name); - if (mounted) Navigator.pop(context, id); - } on ChatException catch (e) { - if (mounted) setState(() => _error = e.message); - } catch (e) { - if (mounted) setState(() => _error = 'Could not start chat. Try again.'); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) => UiDialog( - title: Text('Start a chat'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - key: Key('peerUsername'), - controller: _controller, - autofocus: true, - autocorrect: false, - enableSuggestions: false, - enabled: !_busy, - decoration: InputDecoration( - labelText: "Friend's username", - helperText: '3–20 letters, numbers or underscores', - helperMaxLines: 2, - errorMaxLines: 3, - errorText: _error, - ), - onSubmitted: (_) => _start(), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: _busy ? null : () => Navigator.pop(context), - child: Text('Cancel'), - ), - FilledButton( - key: Key('startChat'), - onPressed: _busy ? null : _start, - child: Text(_busy ? 'Starting…' : 'Start'), - ), - ], - ); -} diff --git a/lib/src/view/groups/contact_picker.dart b/lib/src/view/groups/contact_picker.dart new file mode 100644 index 0000000..c9b976e --- /dev/null +++ b/lib/src/view/groups/contact_picker.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/widgets/avatar.dart'; + +/// A tickable list of contacts, used to start a group and to add people. +class ContactPicker extends ConsumerWidget { + const ContactPicker({ + super.key, + required this.contacts, + required this.selected, + required this.onChanged, + required this.limit, + }); + final List contacts; + final Set selected; + final ValueChanged> onChanged; + + /// How many may be ticked; further rows are disabled once it is reached. + final int limit; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + children: [ + for (final uid in contacts) + Builder( + builder: (context) { + final name = ref.watch(peerUsernameProvider(uid)).value ?? '…'; + final ticked = selected.contains(uid); + return CheckboxListTile( + key: Key('pick_$uid'), + value: ticked, + secondary: PersonAvatar(name: name, size: 36), + title: Text('@$name'), + onChanged: !ticked && selected.length >= limit + ? null + : (on) => onChanged( + on == true + ? {...selected, uid} + : ({...selected}..remove(uid)), + ), + ); + }, + ), + ], + ); + } +} diff --git a/lib/src/view/groups/group_avatar.dart b/lib/src/view/groups/group_avatar.dart new file mode 100644 index 0000000..f61dd98 --- /dev/null +++ b/lib/src/view/groups/group_avatar.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:fireplace/src/widgets/avatar.dart'; + +/// A group's avatar: its name's first letter in a circle, like a person's +/// (owner's choice, decision 0050; the first version used a rounded square). +class GroupAvatar extends StatelessWidget { + const GroupAvatar({super.key, required this.name, this.size = 44}); + final String name; + final double size; + + @override + Widget build(BuildContext context) => PersonAvatar( + // A group invitation has no name to show until it is joined. + name: name.trim().isEmpty ? '#' : name.trim().characters.first, + size: size, + ); +} diff --git a/lib/src/view/groups/group_chat_screen.dart b/lib/src/view/groups/group_chat_screen.dart new file mode 100644 index 0000000..95c7537 --- /dev/null +++ b/lib/src/view/groups/group_chat_screen.dart @@ -0,0 +1,462 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/view/chat/chat_composer.dart'; +import 'package:fireplace/src/view/chat/message_actions.dart'; +import 'package:fireplace/src/view/chat/message_menu_parts.dart'; +import 'package:fireplace/src/view/chat/message_reactions.dart'; +import 'package:fireplace/src/view/chat/message_widgets.dart'; +import 'package:fireplace/src/view/groups/group_info_screen.dart'; +import 'package:fireplace/src/view/groups/group_message_row.dart'; +import 'package:fireplace/src/widgets/app_bar.dart'; +import 'package:fireplace/src/widgets/dialog.dart'; +import 'package:fireplace/src/widgets/page.dart'; +import 'package:fireplace/src/widgets/status.dart'; + +/// A group conversation (decision 0050). It looks like a 1:1 chat: the same +/// bubbles, day separators, held-message menu, reactions and composer, with +/// editing in the composer. What differs is who is speaking: each run of +/// messages from someone else has their name above the first bubble and their +/// avatar beside the last, in that member's own colour. +class GroupChatScreen extends ConsumerStatefulWidget { + const GroupChatScreen({super.key, required this.gid}); + final String gid; + @override + ConsumerState createState() => _GroupChatScreenState(); +} + +class _GroupChatScreenState extends ConsumerState { + final _text = TextEditingController(); + final _edit = TextEditingController(); + final _focus = FocusNode(); + LocalMessage? _editing; + bool _busy = false; + String? _error; + + @override + void initState() { + super.initState(); + _text.addListener(_changed); + _edit.addListener(_changed); + // What is on screen has been seen: it no longer counts as unread. This + // stays on the phone; nobody is told. + ref.listenManual( + groupMessagesProvider(widget.gid), + (_, next) => _seen(next.value), + fireImmediately: true, + ); + } + + void _changed() { + if (mounted) setState(() {}); + } + + void _seen(List? messages) { + final prefs = ref.read(appSessionProvider).value?.chatPreferences; + if (prefs == null || messages == null || !prefs.available) return; + unawaited( + prefs + .markSeen(groupChatId(widget.gid), [ + for (final m in messages) + if (!m.outgoing) m.id, + ]) + .catchError((Object _) {}), + ); + } + + @override + void dispose() { + _text.dispose(); + _edit.dispose(); + _focus.dispose(); + super.dispose(); + } + + Future _run(Future Function() action) async { + setState(() => _error = null); + try { + await action(); + } on GroupActionError catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) { + setState(() => _error = 'That did not go through. Try again.'); + } + } + } + + Future _send() async { + final service = ref.read(groupServiceProvider); + final text = _text.text.trim(); + if (service == null || text.isEmpty) return; + _text.clear(); + await _run(() => service.send(widget.gid, text)); + } + + void _startEdit(LocalMessage message) { + setState(() { + _editing = message; + _error = null; + }); + _edit.value = TextEditingValue( + text: message.body, + selection: TextSelection.collapsed(offset: message.body.length), + ); + _focus.requestFocus(); + } + + void _cancelEdit() { + setState(() => _editing = null); + _edit.clear(); + } + + Future _saveEdit() async { + final service = ref.read(groupServiceProvider); + final target = _editing; + final body = _edit.text.trim(); + if (service == null || target == null || body.isEmpty || _busy) return; + setState(() => _busy = true); + await _run(() => service.edit(widget.gid, target.id, body)); + if (!mounted) return; + setState(() => _busy = false); + if (_error == null) _cancelEdit(); + } + + Future _deleteForEveryone(LocalMessage message) async { + final service = ref.read(groupServiceProvider); + final yes = await showDialog( + context: context, + builder: (ctx) => UiDialog( + title: const Text('Delete for everyone?'), + content: const Text( + 'Ask everyone’s phones to delete this message. They may already ' + 'have seen or saved it.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('confirmRemoteDelete'), + onPressed: () => Navigator.pop(ctx, true), + child: Text( + 'Delete for everyone', + style: TextStyle(color: FireplaceUiTokens.of(ctx).danger), + ), + ), + ], + ), + ); + if (!mounted || yes != true || service == null) return; + if (_editing?.id == message.id) _cancelEdit(); + await _run(() => service.deleteForEveryone(widget.gid, message.id)); + } + + /// The held-message menu: the same controls as a 1:1 chat. Only the author + /// can edit or delete for everyone; every phone enforces that. + List _actionsFor(LocalMessage message, String me) { + final service = ref.read(groupServiceProvider); + if (service == null || message.status == MessageStatus.undecryptable) { + return const []; + } + void react(String emoji) => + unawaited(_run(() => service.react(widget.gid, message.id, emoji))); + final sent = message.status == MessageStatus.ok; + return [ + for (final emoji in quickReactions) + MessageAction( + id: 'react-$emoji', + label: 'React with $emoji', + icon: Icons.emoji_emotions_outlined, + reaction: true, + emoji: emoji, + selected: message.reactions[me]?.emoji == emoji, + onSelected: () => react(emoji), + ), + MessageAction( + id: 'moreReactions', + label: 'More reactions', + icon: Icons.add, + reaction: true, + onEmoji: react, + onSelected: () {}, + ), + if (message.outgoing && sent) + MessageAction( + id: 'edit', + label: 'Edit', + icon: Icons.edit_outlined, + onSelected: () => _startEdit(message), + ), + MessageAction( + id: 'copy', + label: 'Copy', + icon: Icons.copy_outlined, + onSelected: () => + unawaited(Clipboard.setData(ClipboardData(text: message.body))), + ), + if (message.outgoing && sent) + MessageAction( + id: 'deleteForEveryone', + label: 'Delete for everyone', + icon: Icons.delete_outline, + destructive: true, + onSelected: () => unawaited(_deleteForEveryone(message)), + ), + MessageAction( + id: 'deleteForMe', + label: 'Delete for me', + icon: Icons.delete_sweep_outlined, + destructive: true, + onSelected: () => unawaited( + _run(() => service.deleteLocally(widget.gid, message.id)), + ), + ), + ]; + } + + /// Who reacted with what. Tapping your own reaction removes it. + Future _showReactions(LocalMessage message, String me) async { + final service = ref.read(groupServiceProvider); + await showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + builder: (ctx) => SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ListTile(title: Text('Reactions')), + for (final reaction in message.reactions.entries) + if (reaction.value.emoji case final emoji?) + Consumer( + builder: (context, sheetRef, _) => ListTile( + leading: Text(emoji, style: const TextStyle(fontSize: 28)), + title: Text( + reaction.key == me + ? 'You' + : sheetRef + .watch(peerUsernameProvider(reaction.key)) + .value ?? + '…', + ), + subtitle: reaction.key == me + ? const Text('Tap to remove') + : null, + onTap: reaction.key == me && service != null + ? () { + Navigator.pop(ctx); + unawaited( + _run( + () => service.react( + widget.gid, + message.id, + emoji, + ), + ), + ); + } + : null, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final service = ref.watch(groupServiceProvider); + final group = (ref.watch(groupsProvider).value ?? const {})[widget.gid]; + final me = ref.watch(appSessionProvider).value?.uid ?? ''; + final rows = [ + for (final m + in ref.watch(groupMessagesProvider(widget.gid)).value ?? + const []) + if (m.visible && !m.remotelyDeleted) m, + ]; + final colors = ref.watch(groupColorsProvider(widget.gid)).value ?? const {}; + final invited = + ref.watch(groupInvitedProvider(widget.gid)).value ?? const {}; + if (group == null || service == null) { + return Scaffold( + appBar: UiAppBar(context: context, title: const Text('Group')), + body: const UiEmptyState( + title: 'You are not in this group', + message: 'You left it, or it was removed from this phone.', + icon: Icons.group_off_outlined, + ), + ); + } + // People who were invited and have not joined are not counted yet. + final count = group.members + .where((m) => !invited.containsKey(m.uid)) + .length; + final joinedLater = service.joinedLater(widget.gid); + final editing = _editing != null; + bool sameRun(LocalMessage a, LocalMessage? b) => + b != null && + b.status != MessageStatus.notice && + b.senderUid == a.senderUid && + sameLocalDay(a.sentAt, b.sentAt); + return Scaffold( + appBar: UiAppBar( + context: context, + title: InkWell( + key: const Key('groupHeader'), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GroupInfoScreen(gid: widget.gid), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(group.name, maxLines: 1, overflow: TextOverflow.ellipsis), + Text( + '$count ${count == 1 ? 'person' : 'people'}', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + body: Column( + children: [ + Expanded( + child: rows.isEmpty && !joinedLater + ? const UiEmptyState( + icon: Icons.lock_outline_rounded, + title: 'A private group', + message: + 'Messages are end-to-end encrypted. Say hello when ' + 'you’re ready.', + ) + : ListView.builder( + key: const Key('groupMessages'), + reverse: true, + // The same insets as a 1:1 timeline, so a held message is + // lifted without moving. + padding: const EdgeInsets.symmetric( + horizontal: MessageMenuMetrics.edge, + vertical: 12, + ), + itemCount: rows.length + (joinedLater ? 1 : 0), + itemBuilder: (_, i) { + if (i == rows.length) { + // MLS gives a newcomer no keys for earlier messages. + return const GroupQuietLine( + key: Key('historyStart'), + text: 'Messages sent before you joined aren’t shown.', + ); + } + final index = rows.length - 1 - i; + final m = rows[index]; + final before = index == 0 ? null : rows[index - 1]; + final after = index == rows.length - 1 + ? null + : rows[index + 1]; + final showDate = + before == null || + !sameLocalDay(before.sentAt, m.sentAt); + final notice = m.status == MessageStatus.notice + ? GroupNotice.decode(m.body) + : null; + return Column( + key: ValueKey(m.id), + children: [ + if (showDate) DaySeparator(date: m.sentAt), + if (m.status == MessageStatus.notice) + if (notice != null) GroupNoticeLine(notice: notice), + if (m.status != MessageStatus.notice) + GroupMessageRow( + gid: widget.gid, + message: m, + color: memberColor(colors, m.senderUid), + showName: !m.outgoing && !sameRun(m, before), + showAvatar: !m.outgoing && !sameRun(m, after), + actions: _actionsFor(m, me), + onReactions: () => _showReactions(m, me), + ), + if (m.outgoing && + m.status == MessageStatus.unconfirmed) + Align( + alignment: Alignment.centerRight, + child: Text( + 'Not confirmed', + key: Key('unconfirmed_${m.id}'), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + }, + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: UiActionError( + key: const Key('groupError'), + message: _error!, + ), + ), + ChatComposer( + keyboardInset: MediaQuery.viewInsetsOf(context).bottom, + text: editing ? _edit : _text, + name: group.name, + blocked: false, + incomingRequest: false, + waiting: false, + identityHeld: false, + hasSession: true, + contactBusy: false, + onUnblock: () async {}, + focusNode: _focus, + submit: editing ? _saveEdit : _send, + editing: editing, + editingBusy: editing && _busy, + canSubmit: !editing || _edit.text.trim() != _editing!.body, + contextBar: editing + ? Row( + key: const Key('editComposerPreview'), + children: [ + const Expanded( + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 18), + SizedBox(width: 8), + Flexible(child: Text('Edit message')), + ], + ), + ), + ), + IconButton( + key: const Key('cancelEdit'), + tooltip: 'Cancel edit', + onPressed: _cancelEdit, + icon: const Icon(Icons.close), + ), + ], + ) + : null, + ), + ], + ), + ); + } +} diff --git a/lib/src/view/groups/group_info_screen.dart b/lib/src/view/groups/group_info_screen.dart new file mode 100644 index 0000000..2b18ea5 --- /dev/null +++ b/lib/src/view/groups/group_info_screen.dart @@ -0,0 +1,448 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/view/groups/contact_picker.dart'; +import 'package:fireplace/src/view/groups/group_avatar.dart'; +import 'package:fireplace/src/view/groups/text_prompt_dialog.dart'; +import 'package:fireplace/src/widgets/app_bar.dart'; +import 'package:fireplace/src/widgets/avatar.dart'; +import 'package:fireplace/src/widgets/dialog.dart'; +import 'package:fireplace/src/widgets/page.dart'; +import 'package:fireplace/src/widgets/status.dart'; + +/// A group's members and settings. Any member may invite people they chat +/// with; administrators rename the group, remove people and choose the other +/// administrators; anyone may leave. People who were invited and have not +/// answered are listed apart, so nobody is surprised that they are there. +class GroupInfoScreen extends ConsumerStatefulWidget { + const GroupInfoScreen({super.key, required this.gid}); + final String gid; + @override + ConsumerState createState() => _GroupInfoScreenState(); +} + +class _GroupInfoScreenState extends ConsumerState { + bool _busy = false; + String? _error; + + Future _run(Future Function() action) async { + if (_busy) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await action(); + } on GroupRefused catch (e) { + if (mounted) setState(() => _error = e.message); + } on GroupActionError catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) { + setState(() => _error = 'That did not go through. Try again.'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _confirm(String title, String message, String action) async => + await showDialog( + context: context, + builder: (ctx) => UiDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel'), + ), + TextButton( + key: const Key('confirmGroupAction'), + onPressed: () => Navigator.pop(ctx, true), + child: Text(action), + ), + ], + ), + ) ?? + false; + + Future _rename(GroupInfo group) async { + final entered = await showDialog( + context: context, + builder: (_) => TextPromptDialog( + title: 'Rename group', + initial: group.name, + maxLength: GroupLimits.maxNameChars, + fieldKey: const Key('renameField'), + saveKey: const Key('saveRename'), + ), + ); + final name = entered?.trim(); + final service = ref.read(groupServiceProvider); + if (name == null || name.isEmpty || name == group.name || service == null) { + return; + } + await _run(() => service.rename(widget.gid, name)); + } + + /// Make administrator, or Remove as administrator: an administrator's + /// choice for another member (or themselves, while another remains). + Future _changeRole( + GroupMember member, + String name, + bool isAdmin, + ) async { + final service = ref.read(groupServiceProvider); + if (service == null) return; + final choice = await showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile(title: Text('@$name')), + if (isAdmin) + ListTile( + key: const Key('removeAdmin'), + leading: const Icon(Icons.remove_moderator_outlined), + title: const Text('Remove as administrator'), + onTap: () => Navigator.pop(ctx, false), + ) + else + ListTile( + key: const Key('makeAdmin'), + leading: const Icon(Icons.add_moderator_outlined), + title: const Text('Make administrator'), + subtitle: const Text( + 'Administrators rename the group, remove people and choose ' + 'other administrators.', + ), + onTap: () => Navigator.pop(ctx, true), + ), + ], + ), + ), + ); + if (choice == null || !mounted) return; + await _run(() => service.setAdmin(widget.gid, member.uid, admin: choice)); + } + + Future _addPeople(GroupInfo group) async { + final service = ref.read(groupServiceProvider); + if (service == null) return; + final inGroup = {for (final m in group.members) m.uid}; + final picked = await Navigator.of(context).push>( + MaterialPageRoute( + builder: (_) => _AddPeopleScreen( + exclude: inGroup, + room: GroupLimits.maxMembers - inGroup.length, + ), + ), + ); + if (picked == null || picked.isEmpty) return; + await _run(() async { + for (final uid in picked) { + await service.invite(widget.gid, uid); + } + }); + if (mounted && _error == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + picked.length == 1 + ? 'Invitation sent.' + : '${picked.length} invitations sent.', + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final service = ref.watch(groupServiceProvider); + final group = (ref.watch(groupsProvider).value ?? const {})[widget.gid]; + final me = ref.watch(appSessionProvider).value?.uid ?? ''; + if (group == null || service == null) { + return Scaffold( + appBar: UiAppBar(context: context, title: const Text('Group')), + body: const UiEmptyState( + title: 'You are not in this group', + message: 'You left it, or it was removed from this phone.', + icon: Icons.group_off_outlined, + ), + ); + } + final admin = group.isAdmin(me); + final muted = ref.watch(groupMutedProvider(widget.gid)); + final invited = + ref.watch(groupInvitedProvider(widget.gid)).value ?? const {}; + final members = [ + for (final m in group.members) + if (!invited.containsKey(m.uid)) m, + ]..sort((a, b) => (a.uid == me ? 0 : 1).compareTo(b.uid == me ? 0 : 1)); + final waiting = [ + for (final m in group.members) + if (invited.containsKey(m.uid)) m, + ]; + // With nobody on the list still here, everybody administers (as the server). + final named = members.any((m) => m.admin); + return Scaffold( + appBar: UiAppBar(context: context, title: const Text('Group info')), + body: UiPageScroll( + children: [ + const SizedBox(height: 16), + Center(child: GroupAvatar(name: group.name, size: 72)), + const SizedBox(height: 12), + Center( + child: Text( + group.name, + key: const Key('groupInfoName'), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + Center( + child: Text( + '${members.length} ${members.length == 1 ? 'person' : 'people'}' + '${waiting.isEmpty ? '' : ', ${waiting.length} invited'}' + ' (${GroupLimits.maxMembers} at most)', + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.all(16), + child: UiActionError(message: _error!), + ), + const SizedBox(height: 8), + if (group.members.length < GroupLimits.maxMembers) + ListTile( + key: const Key('addPeople'), + enabled: !_busy, + leading: const Icon(Icons.person_add_alt_outlined), + title: const Text('Add people'), + subtitle: const Text('They choose whether to join'), + onTap: () => _addPeople(group), + ), + ListTile( + key: const Key('muteGroup'), + leading: Icon( + muted + ? Icons.notifications_off_outlined + : Icons.notifications_outlined, + ), + title: Text(muted ? 'Unmute' : 'Mute'), + subtitle: Text( + muted + ? 'Its unread messages are not counted on the app icon.' + : 'Only on this phone. Nobody in the group is told.', + ), + onTap: () => _run(() => setGroupMuted(ref, widget.gid, !muted)), + ), + if (admin) + ListTile( + key: const Key('renameGroup'), + enabled: !_busy, + leading: const Icon(Icons.edit_outlined), + title: const Text('Rename group'), + onTap: () => _rename(group), + ), + const Divider(), + for (final m in members) + _MemberRow( + gid: widget.gid, + member: m, + me: m.uid == me, + isAdmin: m.admin || !named, + canRemove: admin && m.uid != me && !_busy, + onRemove: (name) async { + if (await _confirm( + 'Remove @$name?', + 'They will no longer receive messages in this group.', + 'Remove', + )) { + await _run(() => service.remove(widget.gid, m.uid)); + } + }, + // An administrator taps a member to change their role. + onRole: admin && !_busy + ? (name) => _changeRole(m, name, m.admin || !named) + : null, + ), + if (waiting.isNotEmpty) ...[ + const Divider(), + const ListTile( + key: Key('invitedHeader'), + dense: true, + title: Text('Invited'), + subtitle: Text( + 'They have not joined yet. Their phones can already receive ' + 'new messages, so they are listed here.', + ), + ), + for (final m in waiting) + _MemberRow( + gid: widget.gid, + member: m, + me: false, + isAdmin: false, + invited: true, + canRemove: admin && !_busy, + onRemove: (name) async { + if (await _confirm( + 'Withdraw the invitation to @$name?', + 'They will not be able to join unless someone invites ' + 'them again.', + 'Withdraw', + )) { + await _run(() => service.remove(widget.gid, m.uid)); + } + }, + ), + ], + const Divider(), + ListTile( + key: const Key('leaveGroup'), + enabled: !_busy, + leading: Icon( + Icons.logout, + color: Theme.of(context).colorScheme.error, + ), + title: Text( + 'Leave group', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + subtitle: Text( + members.length == 1 + ? 'You are the last member, so the group ends.' + : 'Your copy of past messages stays on this phone.', + ), + onTap: () async { + final navigator = Navigator.of(context); + if (!await _confirm( + 'Leave ${group.name}?', + 'You will stop receiving its messages. Someone can invite ' + 'you again later.', + 'Leave', + )) { + return; + } + await _run(() => service.leave(widget.gid)); + if (mounted && _error == null) { + navigator.popUntil((r) => r.isFirst); + } + }, + ), + ], + ), + ); + } +} + +/// One person in the member list. +class _MemberRow extends ConsumerWidget { + const _MemberRow({ + required this.gid, + required this.member, + required this.me, + required this.isAdmin, + required this.canRemove, + required this.onRemove, + this.invited = false, + this.onRole, + }); + final String gid; + final GroupMember member; + final bool me, isAdmin, canRemove, invited; + final ValueChanged onRemove; + final ValueChanged? onRole; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final name = ref.watch(peerUsernameProvider(member.uid)).value ?? '…'; + // Other people's avatars use their own colour, as in the conversation. + final colors = ref.watch(groupColorsProvider(gid)).value ?? const {}; + return ListTile( + key: Key('member_${member.uid}'), + leading: PersonAvatar( + name: name, + size: 40, + color: me + ? null + : memberColor( + colors, + member.uid, + ).accentText(Theme.of(context).brightness), + ), + title: Text(me ? 'You' : '@$name'), + subtitle: invited + ? const Text('Invited') + : isAdmin + ? const Text('Administrator') + : null, + onTap: onRole == null ? null : () => onRole!(name), + trailing: canRemove + ? IconButton( + key: Key('remove_${member.uid}'), + tooltip: invited ? 'Withdraw invitation' : 'Remove from group', + icon: const Icon(Icons.person_remove_outlined), + onPressed: () => onRemove(name), + ) + : null, + ); + } +} + +/// Picks contacts to invite into an existing group. +class _AddPeopleScreen extends ConsumerStatefulWidget { + const _AddPeopleScreen({required this.exclude, required this.room}); + final Set exclude; + final int room; + @override + ConsumerState<_AddPeopleScreen> createState() => _AddPeopleScreenState(); +} + +class _AddPeopleScreenState extends ConsumerState<_AddPeopleScreen> { + Set _picked = const {}; + + @override + Widget build(BuildContext context) { + final contacts = [ + for (final uid in ref.watch(groupContactsProvider)) + if (!widget.exclude.contains(uid)) uid, + ]; + return Scaffold( + appBar: UiAppBar( + context: context, + title: const Text('Add people'), + actions: [ + TextButton( + key: const Key('sendInvites'), + onPressed: _picked.isEmpty + ? null + : () => Navigator.pop(context, _picked), + child: const Text('Invite'), + ), + ], + ), + body: contacts.isEmpty + ? const UiEmptyState( + title: 'No one else to invite', + message: 'Everyone you chat with is already here.', + icon: Icons.group_outlined, + ) + : UiPageScroll( + children: [ + ContactPicker( + contacts: contacts, + selected: _picked, + limit: widget.room, + onChanged: (s) => setState(() => _picked = s), + ), + ], + ), + ); + } +} diff --git a/lib/src/view/groups/group_invitation_card.dart b/lib/src/view/groups/group_invitation_card.dart new file mode 100644 index 0000000..21bfb36 --- /dev/null +++ b/lib/src/view/groups/group_invitation_card.dart @@ -0,0 +1,123 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/view/groups/group_avatar.dart'; +import 'package:fireplace/src/view/groups/group_chat_screen.dart'; +import 'package:fireplace/src/widgets/status.dart'; + +/// An invitation to a group, with Join and Decline. The group's name is +/// encrypted for its members, so it shows once you have joined. +class GroupInvitationCard extends ConsumerStatefulWidget { + const GroupInvitationCard({super.key, required this.invitation}); + final GroupInvitation invitation; + @override + ConsumerState createState() => + _GroupInvitationCardState(); +} + +class _GroupInvitationCardState extends ConsumerState { + bool _busy = false; + String? _error; + + Future _run(Future Function(String gid) action) async { + final service = ref.read(groupServiceProvider); + if (service == null || _busy) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await action(widget.invitation.gid); + } on GroupRefused catch (e) { + if (mounted) setState(() => _error = e.message); + } on GroupActionError catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) { + setState(() => _error = 'Could not answer this invitation. Try again.'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final service = ref.watch(groupServiceProvider); + final inviter = + ref.watch(peerUsernameProvider(widget.invitation.inviter)).value ?? '…'; + return Card( + key: Key('groupInvite_${widget.invitation.gid}'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const GroupAvatar(name: '', size: 40), + const SizedBox(width: 12), + Expanded( + child: Text( + '@$inviter invited you to a group', + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'If you join, everyone in the group can read what you send ' + 'there. You can leave at any time.', + ), + if (_error != null) UiActionError(message: _error!), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + key: const Key('declineGroup'), + onPressed: _busy || service == null + ? null + : () => _run(service.decline), + child: const Text('Decline'), + ), + const SizedBox(width: 8), + FilledButton( + key: const Key('joinGroup'), + onPressed: _busy || service == null + ? null + : () async { + // Joining takes the card away, so keep what opening + // the group needs. + final navigator = Navigator.of(context); + final gid = widget.invitation.gid; + var joined = false; + await _run((g) async { + await service.accept(g); + joined = true; + }); + if (!joined) return; + // Straight into the conversation, as the owner asked. + unawaited( + navigator.push( + MaterialPageRoute( + builder: (_) => GroupChatScreen(gid: gid), + ), + ), + ); + }, + child: const Text('Join'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/view/groups/group_message_row.dart b/lib/src/view/groups/group_message_row.dart new file mode 100644 index 0000000..f6b9d54 --- /dev/null +++ b/lib/src/view/groups/group_message_row.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/styles/chat_colors.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/view/chat/message_actions.dart'; +import 'package:fireplace/src/view/chat/message_reactions.dart'; +import 'package:fireplace/src/view/chat/message_widgets.dart'; +import 'package:fireplace/src/widgets/avatar.dart'; + +/// Layout shared by the rows of a group conversation. +abstract final class GroupRowMetrics { + static const double avatar = 30; + + /// The avatar and the gap after it: where other people's bubbles start. + static const double gutter = avatar + 8; +} + +/// One message in a group. The bubble, its held menu and its reactions are the +/// 1:1 ones. A message from someone else also has the sender's name above the +/// first bubble of a run and their avatar beside the last, and uses the soft +/// tint of that member's own colour. +class GroupMessageRow extends ConsumerWidget { + const GroupMessageRow({ + super.key, + required this.gid, + required this.message, + required this.color, + required this.showName, + required this.showAvatar, + required this.actions, + required this.onReactions, + }); + final String gid; + final LocalMessage message; + + /// The sender's own primary colour (Stone until it arrives). + final ChatBubbleColor color; + final bool showName, showAvatar; + final List actions; + final VoidCallback onReactions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mine = message.outgoing; + final brightness = Theme.of(context).brightness; + final name = mine + ? 'You' + : ref.watch(peerUsernameProvider(message.senderUid)).value ?? '…'; + final reserve = + message.reactions.values.any((reaction) => reaction.emoji != null) + ? ReactionChipLayout.reserve * MediaQuery.textScalerOf(context).scale(1) + : 0.0; + final bubble = Stack( + children: [ + Padding( + padding: EdgeInsets.only(bottom: reserve), + child: MessageBubble( + message: message, + senderName: name, + actions: actions, + incomingColor: mine ? null : color, + liveOf: (previewRef) => previewRef + .watch(groupMessagesProvider(gid)) + .value + ?.where((m) => m.id == message.id) + .firstOrNull, + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MessageReactions( + message: message, + peerName: name, + onSelect: onReactions, + ), + ), + ], + ); + if (mine) return bubble; + final shade = color.accentText(brightness); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showName) + Padding( + padding: const EdgeInsets.only( + left: GroupRowMetrics.gutter + 14, + top: 6, + ), + child: Text( + name, + key: Key('groupSender_${message.id}'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: shade, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox( + width: GroupRowMetrics.gutter, + child: showAvatar + ? Padding( + // Level with the bubble, above its margin and reactions. + padding: EdgeInsets.only(right: 8, bottom: 4 + reserve), + child: PersonAvatar( + key: Key('groupAvatar_${message.id}'), + name: name, + size: GroupRowMetrics.avatar, + color: shade, + ), + ) + : null, + ), + Expanded(child: bubble), + ], + ), + ], + ); + } +} + +/// A small centred line in a group's history. +class GroupQuietLine extends StatelessWidget { + const GroupQuietLine({super.key, required this.text}); + final String text; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 6), + child: Text( + text, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 13, + height: 1.3, + color: FireplaceUiTokens.of(context).secondaryText, + ), + ), + ); +} + +/// A change to the group ("alice added bob_jones"), with names put in. +class GroupNoticeLine extends ConsumerWidget { + const GroupNoticeLine({super.key, required this.notice}); + final GroupNotice notice; + + @override + Widget build(BuildContext context, WidgetRef ref) => + GroupQuietLine(text: groupNoticeText(ref, notice)); +} + +/// The words for a notice, with "You" for this account and usernames for +/// everyone else. +String groupNoticeText(WidgetRef ref, GroupNotice notice) { + final me = ref.watch(appSessionProvider.select((s) => s.value?.uid)); + return notice.text( + (uid) => + uid == me ? 'You' : ref.watch(peerUsernameProvider(uid)).value ?? '…', + ); +} diff --git a/lib/src/view/groups/group_tile.dart b/lib/src/view/groups/group_tile.dart new file mode 100644 index 0000000..0dd8d53 --- /dev/null +++ b/lib/src/view/groups/group_tile.dart @@ -0,0 +1,243 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/model/chat/message_controls.dart'; +import 'package:fireplace/src/model/chat/message_format.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/styles/design_tokens.dart'; +import 'package:fireplace/src/view/chat_list/chat_list_stamp.dart'; +import 'package:fireplace/src/view/chat_list/chat_row.dart'; +import 'package:fireplace/src/view/groups/group_avatar.dart'; +import 'package:fireplace/src/view/groups/group_message_row.dart'; + +/// A group in the chat list. The row is a 1:1 row (the same avatar size, bold +/// name, date, two-line preview, unread count and spacing) with one +/// difference: the preview names who wrote the newest message +/// ("Mira: See you at 7?"). The avatar is a circle with the group's initial. +class GroupTile extends ConsumerWidget { + const GroupTile({super.key, required this.group, required this.onOpen}); + final GroupInfo group; + final VoidCallback onOpen; + + Future _toggleMute( + BuildContext context, + WidgetRef ref, + bool muted, + ) async { + try { + await setGroupMuted(ref, group.gid, !muted); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Could not save the mute preference. Try again.'), + ), + ); + } + } + } + + /// The same menu a 1:1 row opens when it is held. + Future _showActions( + BuildContext context, + WidgetRef ref, + bool muted, + Offset? at, + ) async { + final box = context.findRenderObject()! as RenderBox; + final overlay = + Overlay.of(context).context.findRenderObject()! as RenderBox; + final point = at ?? box.localToGlobal(box.size.center(Offset.zero)); + final chosen = await showMenu( + context: context, + position: RelativeRect.fromRect( + point & Size.zero, + Offset.zero & overlay.size, + ), + items: [ + PopupMenuItem( + value: 'mute', + child: Text(muted ? 'Unmute chat' : 'Mute chat'), + ), + ], + ); + if (chosen == 'mute' && context.mounted) { + await _toggleMute(context, ref, muted); + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final t = FireplaceUiTokens.of(context); + final muted = ref.watch(groupMutedProvider(group.gid)); + var pressedAt = Offset.zero; + final messages = + ref.watch(groupMessagesProvider(group.gid)).value ?? + const []; + final last = messages + .where((m) => m.visible && !m.remotelyDeleted) + .lastOrNull; + final invited = + ref.watch(groupInvitedProvider(group.gid)).value ?? const {}; + final people = group.members + .where((m) => !invited.containsKey(m.uid)) + .length; + final unread = ref.watch(groupUnreadProvider(group.gid)); + final notice = last?.status == MessageStatus.notice + ? GroupNotice.decode(last!.body) + : null; + final String preview; + if (last == null) { + preview = '$people ${people == 1 ? 'person' : 'people'}'; + } else if (last.status == MessageStatus.notice) { + preview = notice == null ? '' : groupNoticeText(ref, notice); + } else { + final sender = last.outgoing + ? (last.status == MessageStatus.unconfirmed + ? 'You (not confirmed)' + : 'You') + : ref.watch(peerUsernameProvider(last.senderUid)).value ?? '…'; + preview = + '$sender: ${last.status == MessageStatus.undecryptable ? 'Unreadable encrypted message' : messagePreview(last.body)}'; + } + // The same sizes as ChatTile: they are for the default text size and grow + // with the system setting. + final scaler = MediaQuery.textScalerOf(context); + final nameLine = scaler.scale(16) * 1.2; + final line = scaler.scale(15) * 1.33; + return InkWell( + key: Key('group_${group.gid}'), + onTap: onOpen, + onTapDown: (details) => pressedAt = details.globalPosition, + onLongPress: () => _showActions(context, ref, muted, pressedAt), + onSecondaryTapUp: (details) => + _showActions(context, ref, muted, details.globalPosition), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: ChatTile.edge), + child: Row( + children: [ + GroupAvatar(name: group.name, size: ChatTile.avatarSize), + const SizedBox(width: 10), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: ChatTile.avatarSize + 16, + ), + child: Padding( + padding: EdgeInsets.only( + top: scaler.scale(8), + bottom: scaler.scale(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // The date's smaller type must not stretch the line. + SizedBox( + height: nameLine, + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + group.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 16, + height: 1.2, + fontWeight: FontWeight.w600, + color: t.text, + ), + ), + ), + if (muted) + Padding( + padding: const EdgeInsets.only(left: 6), + child: Icon( + Icons.notifications_off, + key: ValueKey('muted-${group.chatId}'), + size: scaler.scale(12), + color: t.secondaryText, + semanticLabel: 'Muted', + ), + ), + ], + ), + ), + if (last != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + chatListStamp( + last.sentAt, + ref.watch(messageControlNowProvider)(), + MaterialLocalizations.of(context), + ), + maxLines: 1, + style: TextStyle( + fontSize: 14, + height: 16 * 1.2 / 14, + fontWeight: FontWeight.w400, + color: t.secondaryText, + ), + ), + ), + ], + ), + ), + ConstrainedBox( + // The count sits beside the second preview line even + // when the preview is shorter. + constraints: BoxConstraints(minHeight: line * 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + preview, + key: Key('groupPreview_${group.gid}'), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + height: 1.33, + fontWeight: FontWeight.w400, + color: t.secondaryText, + ), + ), + ), + if (unread > 0) + Padding( + padding: EdgeInsets.only( + left: 8, + top: + line + + (line - scaler.scale(UnreadCount.size)) / + 2, + ), + child: UnreadCount( + key: ValueKey('unread-${group.chatId}'), + count: unread, + muted: muted, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/view/groups/new_group_screen.dart b/lib/src/view/groups/new_group_screen.dart new file mode 100644 index 0000000..5f1ea2e --- /dev/null +++ b/lib/src/view/groups/new_group_screen.dart @@ -0,0 +1,113 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/view/groups/contact_picker.dart'; +import 'package:fireplace/src/widgets/app_bar.dart'; +import 'package:fireplace/src/widgets/page.dart'; +import 'package:fireplace/src/widgets/status.dart'; + +/// Names a new group and picks who to invite. People are invited, not added: +/// each one joins only after accepting. Pops with the new group's id. +class NewGroupScreen extends ConsumerStatefulWidget { + const NewGroupScreen({super.key}); + @override + ConsumerState createState() => _NewGroupScreenState(); +} + +class _NewGroupScreenState extends ConsumerState { + final _name = TextEditingController(); + Set _picked = const {}; + bool _busy = false; + String? _error; + + @override + void dispose() { + _name.dispose(); + super.dispose(); + } + + bool get _ready => + _name.text.trim().isNotEmpty && _picked.isNotEmpty && !_busy; + + Future _create() async { + final service = ref.read(groupServiceProvider); + if (service == null || !_ready) return; + setState(() { + _busy = true; + _error = null; + }); + try { + final gid = await service.create(_name.text.trim(), _picked.toList()); + if (mounted) Navigator.of(context).pop(gid); + } on GroupRefused catch (e) { + if (mounted) setState(() => _error = e.message); + } on GroupActionError catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) { + setState(() => _error = 'Could not create the group. Try again.'); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final contacts = ref.watch(groupContactsProvider); + return Scaffold( + appBar: UiAppBar( + context: context, + title: const Text('New group'), + actions: [ + TextButton( + key: const Key('createGroup'), + onPressed: _ready ? _create : null, + child: const Text('Create'), + ), + ], + ), + body: UiPageScroll( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + key: const Key('groupName'), + controller: _name, + maxLength: GroupLimits.maxNameChars, + textCapitalization: TextCapitalization.sentences, + decoration: const InputDecoration(labelText: 'Group name'), + onChanged: (_) => setState(() {}), + ), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: UiActionError(message: _error!), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + 'Invite people you chat with (up to ' + '${GroupLimits.maxMembers - 1}). They join once they accept.', + ), + ), + if (contacts.isEmpty) + const UiEmptyState( + title: 'No one to invite yet', + message: 'Start a chat with someone first, then make a group.', + icon: Icons.group_add_outlined, + ) + else + ContactPicker( + contacts: contacts, + selected: _picked, + limit: GroupLimits.maxMembers - 1, + onChanged: (s) => setState(() => _picked = s), + ), + ], + ), + ); + } +} diff --git a/lib/src/view/groups/text_prompt_dialog.dart b/lib/src/view/groups/text_prompt_dialog.dart new file mode 100644 index 0000000..ff365c5 --- /dev/null +++ b/lib/src/view/groups/text_prompt_dialog.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:fireplace/src/widgets/dialog.dart'; + +/// Asks for one line of text and pops with it (or null on Cancel). The dialog +/// owns its controller: disposing it in the caller while the dialog was still +/// animating closed crashed the app on the device. +class TextPromptDialog extends StatefulWidget { + const TextPromptDialog({ + super.key, + required this.title, + required this.initial, + this.maxLength, + this.fieldKey, + this.saveKey, + }); + final String title, initial; + final int? maxLength; + final Key? fieldKey, saveKey; + + @override + State createState() => _TextPromptDialogState(); +} + +class _TextPromptDialogState extends State { + late final _controller = TextEditingController(text: widget.initial); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => UiDialog( + title: Text(widget.title), + content: TextField( + key: widget.fieldKey, + controller: _controller, + autofocus: true, + maxLines: null, + maxLength: widget.maxLength, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + key: widget.saveKey, + onPressed: () => Navigator.pop(context, _controller.text), + child: const Text('Save'), + ), + ], + ); +} diff --git a/lib/src/view/safety/requests_screen.dart b/lib/src/view/safety/requests_screen.dart index 78e9cfd..fab1947 100644 --- a/lib/src/view/safety/requests_screen.dart +++ b/lib/src/view/safety/requests_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/group_providers.dart'; import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/view/groups/group_invitation_card.dart'; import 'package:fireplace/src/model/chat/chat_service.dart'; import 'package:fireplace/src/view/chat/chat_screen.dart'; import 'package:fireplace/src/view/safety/confirm_block.dart'; @@ -74,9 +76,10 @@ class RequestsScreen extends ConsumerWidget { !hidden.contains(c.chatId)) c, ]; + final invitations = ref.watch(groupInvitationsProvider).value ?? const []; return Scaffold( appBar: UiAppBar(context: context, title: Text('Message requests')), - body: requests.isEmpty + body: requests.isEmpty && invitations.isEmpty ? const UiEmptyState( title: 'No message requests', message: 'New requests will appear here. Messages stay hidden until you accept.', @@ -84,14 +87,20 @@ class RequestsScreen extends ConsumerWidget { ) : UiPageScroll( children: [ - Padding( - padding: EdgeInsets.all(16), - child: Text( - 'These people want to chat. You will not see what they ' - 'wrote until you accept. They cannot see whether you ' - 'have looked.', + for (final i in invitations) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: GroupInvitationCard(invitation: i), + ), + if (requests.isNotEmpty) + Padding( + padding: EdgeInsets.all(16), + child: Text( + 'These people want to chat. You will not see what they ' + 'wrote until you accept. They cannot see whether you ' + 'have looked.', + ), ), - ), for (final c in requests) _RequestTile(chat: c, session: session!), ], diff --git a/lib/src/widgets/avatar.dart b/lib/src/widgets/avatar.dart index 62fbe94..f9124d0 100644 --- a/lib/src/widgets/avatar.dart +++ b/lib/src/widgets/avatar.dart @@ -17,9 +17,17 @@ String avatarInitials(String name) { /// Initials on the chosen accent colour. There are no profile photos. class PersonAvatar extends StatelessWidget { - const PersonAvatar({super.key, required this.name, this.size = 44}); + const PersonAvatar({ + super.key, + required this.name, + this.size = 44, + this.color, + }); final String name; final double size; + + /// The fill, when it is not the app's accent: a group member's own colour. + final Color? color; @override Widget build(BuildContext context) { final t = FireplaceUiTokens.of(context); @@ -35,7 +43,10 @@ class PersonAvatar extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color.lerp(t.accent, Colors.white, .18)!, t.accent], + colors: [ + Color.lerp(color ?? t.accent, Colors.white, .18)!, + color ?? t.accent, + ], ), ), child: Text( diff --git a/packages/fireplace_native/lib/fireplace_crypto.dart b/packages/fireplace_native/lib/fireplace_crypto.dart index 63aa809..3fd296a 100644 --- a/packages/fireplace_native/lib/fireplace_crypto.dart +++ b/packages/fireplace_native/lib/fireplace_crypto.dart @@ -10,6 +10,7 @@ import 'src/crypto/memory.dart'; part 'src/crypto/calls.dart'; part 'src/crypto/sessions.dart'; part 'src/crypto/api.dart'; +part 'src/crypto/groups.dart'; class NativeCryptoException implements Exception { const NativeCryptoException(this.code); diff --git a/packages/fireplace_native/lib/src/crypto/groups.dart b/packages/fireplace_native/lib/src/crypto/groups.dart new file mode 100644 index 0000000..c4ec33d --- /dev/null +++ b/packages/fireplace_native/lib/src/crypto/groups.dart @@ -0,0 +1,128 @@ +part of '../../fireplace_crypto.dart'; + +/// One device's MLS group store in the Rust core (Cargo feature `groups`, +/// mobile decision 0050). Requests and results are JSON; see the core's +/// `src/group/mod.rs`. A store can hold several megabytes, so this binding +/// allows larger buffers than the 1:1 calls. +class NativeGroupStore implements Finalizable { + NativeGroupStore._(this._core, this._handle) + : _finalizer = _finalizerFor(_core) { + try { + _finalizer.attach(this, _handle, detach: this); + } catch (_) { + _destroy(_handle); + _handle = nullptr; + rethrow; + } + } + + static const maxBytes = 64 * 1024 * 1024; + + final NativeCrypto _core; + Pointer _handle; + final NativeFinalizer _finalizer; + static final _finalizers = {}; + static NativeFinalizer _finalizerFor(NativeCrypto core) { + final callback = core.library + .lookup)>>( + 'fp_group_destroy', + ); + return _finalizers.putIfAbsent( + callback.address, + () => NativeFinalizer(callback), + ); + } + + late final _destroy = _core.library + .lookupFunction< + Void Function(Pointer), + void Function(Pointer) + >('fp_group_destroy'); + late final _call = _core.library + .lookupFunction< + Int32 Function( + Pointer, + Pointer, + UintPtr, + Pointer, + ), + int Function(Pointer, Pointer, int, Pointer) + >('fp_group_call'); + late final _records = _core.library + .lookupFunction< + Int32 Function(Pointer, Pointer), + int Function(Pointer, Pointer) + >('fp_group_records'); + + /// Runs one request. Throws [NativeCryptoException] with the core's code. + Map call(Map request) { + _ensureOpen(); + final bytes = NativeCrypto.jsonBytes(request); + return _core._scope((scope) { + final input = _input(scope, bytes); + final out = scope.output(); + NativeCrypto._check(_call(_handle, input, bytes.length, out)); + return NativeCrypto.decodeJson(_copy(out)); + }); + } + + /// Every record, as the length-prefixed blob `fp_group_open` takes. + Uint8List records() { + _ensureOpen(); + return _core._scope((scope) { + final out = scope.output(); + NativeCrypto._check(_records(_handle, out)); + return _copy(out); + }); + } + + void close() { + if (_handle == nullptr) return; + _finalizer.detach(this); + _destroy(_handle); + _handle = nullptr; + } + + bool get isClosed => _handle == nullptr; + + void _ensureOpen() { + if (_handle == nullptr) throw StateError('The group store is closed.'); + } + + static Pointer _input(NativeScope scope, List bytes) { + if (bytes.length > maxBytes) { + throw const FormatException('Group request exceeds the size limit.'); + } + final pointer = scope.allocate(bytes.length); + pointer.asTypedList(bytes.length).setAll(0, bytes); + return pointer; + } + + static Uint8List _copy(Pointer output) { + final size = output.ref.len; + if (size == 0) return Uint8List(0); + if (output.ref.ptr == nullptr || size > maxBytes) { + throw StateError('Invalid group output buffer.'); + } + return Uint8List.fromList(output.ref.ptr.asTypedList(size)); + } +} + +extension NativeCryptoGroups on NativeCrypto { + /// Opens a store from saved records (empty for a new device). + NativeGroupStore openGroupStore(List records) { + final open = library + .lookupFunction< + Int32 Function(Pointer, UintPtr, Pointer>), + int Function(Pointer, int, Pointer>) + >('fp_group_open'); + return _scope((scope) { + final input = NativeGroupStore._input(scope, records); + final handle = scope + .allocate(sizeOf>()) + .cast>(); + NativeCrypto._check(open(input, records.length, handle)); + return NativeGroupStore._(this, handle.value); + }); + } +} diff --git a/packages/fireplace_native/rust/Cargo.lock b/packages/fireplace_native/rust/Cargo.lock index 1fcd432..4836b47 100644 --- a/packages/fireplace_native/rust/Cargo.lock +++ b/packages/fireplace_native/rust/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + [[package]] name = "aead" version = "0.6.1" @@ -9,7 +19,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ "crypto-common 0.2.2", - "inout", + "inout 0.2.6", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", ] [[package]] @@ -18,45 +39,105 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cipher", + "cipher 0.5.2", "cpubits", "cpufeatures 0.3.1", "zeroize", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", + "subtle", +] + [[package]] name = "aes-gcm" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", + "aead 0.6.1", + "aes 0.9.1", + "cipher 0.5.2", + "ctr 0.10.1", "ctutils", - "ghash", + "ghash 0.6.0", "zeroize", ] +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64ct" version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bitflags" version = "2.13.2" @@ -82,12 +163,83 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bumpalo" +version = "3.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a0cf66fd1eb2fb7b822cb78370d82303750809bc6ba855f3f58e517d3238db" + +[[package]] +name = "cc" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ef9e3e8154a45c7a95cff4c1299702106ad94062d7bb85fdc5d3a5afd91d45" +dependencies = [ + "find-msvc-tools", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.0", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead 0.5.2", + "chacha20 0.9.1", + "cipher 0.4.4", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", + "zeroize", +] + [[package]] name = "cipher" version = "0.5.2" @@ -96,7 +248,18 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ "block-buffer 0.12.1", "crypto-common 0.2.2", - "inout", + "inout 0.2.6", +] + +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", ] [[package]] @@ -105,12 +268,29 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be95d4a884358a21a199689323c29e9256b4a1b315e22f183d155422041d3a9a" +[[package]] +name = "core-models" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3344d349528f449816cfa85e558c439bdca5a6d8a738cfb21e69f0b2b1952f" +dependencies = [ + "hax-lib", + "pastey", + "rand", +] + [[package]] name = "cpubits" version = "0.1.1" @@ -135,6 +315,54 @@ dependencies = [ "libc", ] +[[package]] +name = "crabgrind" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7459e07732f6de74001fcf73a3fdfbb0f368e4fd8e2392f4a2206a065e6e95" +dependencies = [ + "bindgen", + "cc", + "pkg-config", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -142,6 +370,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -151,8 +380,18 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.1", "hybrid-array", - "rand_core", + "rand_core 0.10.0", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", ] [[package]] @@ -161,7 +400,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" dependencies = [ - "cipher", + "cipher 0.5.2", ] [[package]] @@ -173,6 +412,22 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -183,7 +438,7 @@ dependencies = [ "cpufeatures 0.3.1", "curve25519-dalek-derive", "digest 0.11.3", - "fiat-crypto", + "fiat-crypto 0.3.0", "rustc_version", "subtle", "zeroize", @@ -200,16 +455,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + [[package]] name = "der" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" dependencies = [ - "const-oid", + "const-oid 0.10.3", "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" +dependencies = [ + "serde_core", +] + [[package]] name = "digest" version = "0.10.7" @@ -217,6 +492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -228,18 +504,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid 0.10.3", "crypto-common 0.2.2", + "ctutils", "zeroize", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.11.0", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", ] [[package]] @@ -248,11 +576,38 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", "serde", "sha2 0.11.1", - "signature", + "signature 3.0.0", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", "subtle", "zeroize", ] @@ -263,28 +618,66 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + [[package]] name = "fireplace-crypto" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d230c2cdc16ebad88c19ae86fe5c66469f1b9d8dc167288c0d0a6c23b9a307" +source = "git+https://github.com/fireplacechat/fireplace-crypto?rev=276eaacce7764f2c4914eb975040d9347db18079#276eaacce7764f2c4914eb975040d9347db18079" dependencies = [ - "aes-gcm", - "ed25519-dalek", - "getrandom", - "hkdf", + "aes-gcm 0.11.1", + "ed25519-dalek 3.0.0", + "getrandom 0.4.1", + "hkdf 0.12.4", "ml-dsa", "ml-kem", - "rand_core", + "openmls", + "openmls_basic_credential", + "openmls_memory_storage", + "openmls_rust_crypto", + "openmls_traits", + "rand_core 0.10.0", "sha2 0.10.9", "subtle", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -301,6 +694,39 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -309,32 +735,71 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "r-efi", - "rand_core", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.0", "wasip2", "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval 0.6.2", +] + [[package]] name = "ghash" version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" dependencies = [ - "polyval", + "polyval 0.7.3", "zeroize", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -350,19 +815,83 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "hax-lib" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a33cb227f97ee5c419064c31d7c55a7d895f28d8019ccdadc311cc036500c4" +dependencies = [ + "hax-lib-macros", + "num-bigint", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28da835a5f153e9f3d0cc1f42ee605a1cfa9093c93348193793d60a1b0908b51" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed327e9a28d6ee018a4a9ba842d4ee27e378c8a591d2beb6f42f32b24a02fcb" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] + [[package]] name = "hkdf" version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -374,6 +903,84 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hpke-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812de62ab573b876c6c40d7553bae9402ae3bad95b64af06f964388eecb9b7b1" +dependencies = [ + "hpke-rs-crypto", + "hpke-rs-libcrux", + "hpke-rs-rust-crypto", + "libcrux-sha3", + "log", + "serde", + "subtle", + "tls_codec", + "zeroize", +] + +[[package]] +name = "hpke-rs-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c461da2e0c9c93d875b597f0c78214fb0d2d5c57834b2ef2a2fa057fa20e24" +dependencies = [ + "rand", + "zeroize", +] + +[[package]] +name = "hpke-rs-libcrux" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1ace95ef11fbbd84527eada5fc1304f66720fc4c008db30d888d62d3c52613" +dependencies = [ + "hpke-rs-crypto", + "libcrux-aead", + "libcrux-ecdh", + "libcrux-hkdf", + "libcrux-kem", + "libcrux-traits", + "rand", + "rand_chacha 0.10.0", + "rand_core 0.10.0", + "zeroize", +] + +[[package]] +name = "hpke-rs-rust-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a606ac19851da841862ef5f39d26d6227bfcfb4047417801a66c1f6e6652d71" +dependencies = [ + "aes-gcm 0.10.3", + "chacha20poly1305", + "hkdf 0.13.0", + "hpke-rs-crypto", + "k256", + "ml-kem", + "p256", + "p384", + "rand", + "rand_chacha 0.10.0", + "rand_core 0.10.0", + "sha2 0.11.1", + "subtle", + "x-wing", + "x25519-dalek 2.0.1", + "zeroize", +] + [[package]] name = "hybrid-array" version = "0.4.15" @@ -385,12 +992,116 @@ dependencies = [ "zeroize", ] +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -403,6 +1114,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inout" version = "0.2.6" @@ -412,12 +1132,42 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bd1574e617a9a6c1f8f8181bd7e841e1f20e4277607ef28945765188d6284d4" +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve", +] + [[package]] name = "keccak" version = "0.2.2" @@ -435,7 +1185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" dependencies = [ "crypto-common 0.2.2", - "rand_core", + "rand_core 0.10.0", ] [[package]] @@ -451,170 +1201,1175 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] -name = "log" -version = "0.4.35" +name = "libcrux-aead" +version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +checksum = "22acbe68be84b7b41aaba6e39b87036fc4324dee628d94773750bf3d7e906d69" +dependencies = [ + "libcrux-aes", + "libcrux-chacha20poly1305", + "libcrux-secrets", + "libcrux-traits", +] [[package]] -name = "memchr" -version = "2.8.3" +name = "libcrux-aes" +version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "57cc95b11dbc797b1e169467b1fc4205c4e6ee2ce516a035ad7342ce5f6ae971" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] [[package]] -name = "ml-dsa" -version = "0.1.2" +name = "libcrux-chacha20poly1305" +version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f6d49c3f3f0e10e18ca975ffda66f8da0ec36521c1d911adcbc521bf0743f5c" +checksum = "537e6eee5cdc9a980014d058784b0be09614f0596df789b114f7833aa9f35d75" dependencies = [ - "crypto-common 0.2.2", - "ctutils", - "hybrid-array", - "module-lattice", - "pkcs8", - "shake", - "signature", - "zeroize", + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", ] [[package]] -name = "ml-kem" -version = "0.3.4" +name = "libcrux-curve25519" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ca78b66f46050d49fa21693ae8adab2081d8101b65d8067316b6b70b64a0d1" +checksum = "e4f3cfd5e31cb9745e290ee061222c380ec42884654b09fc7d12a5b0ed63e028" dependencies = [ - "hybrid-array", - "kem", - "module-lattice", - "pkcs8", - "rand_core", - "sha3", - "shake", - "zeroize", + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", ] [[package]] -name = "module-lattice" -version = "0.2.4" +name = "libcrux-ecdh" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a1e412c8ee83a1c678f427806219c49c96b2511b9f07497514f0e43fb26155" +checksum = "4a227590b89ff55ce7cd97b5a7468c82d231db8f3b890bb4d4fb6d271e7524d7" dependencies = [ - "ctutils", - "hybrid-array", - "num-traits", - "zeroize", + "libcrux-curve25519", + "libcrux-p256", + "rand", + "tls_codec", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "libcrux-ed25519" +version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "cc463d32f41c47e03dc664547596928a13cb3d79c03453a9b3fe654649313982" dependencies = [ - "autocfg", + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "rand_core 0.10.0", ] [[package]] -name = "pkcs8" -version = "0.11.0" +name = "libcrux-hacl-rs" +version = "0.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +checksum = "66106db376ae249af86911aee048cf73ea1f394d6653026fc988dd22cf2a4ace" dependencies = [ - "der", - "spki", + "libcrux-macros", ] [[package]] -name = "polyval" -version = "0.7.3" +name = "libcrux-hkdf" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +checksum = "dc94e651dca4d47edbcdd88bb2428ce16a2bd86b7a4e7170da87b505478f9839" dependencies = [ - "cpubits", - "cpufeatures 0.3.1", - "universal-hash", - "zeroize", + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", ] [[package]] -name = "prettyplease" -version = "0.2.37" +name = "libcrux-hmac" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "500ad9b32c715161b594172ca1fb11503a1c033b393ff4c0c33505ce51c1943c" dependencies = [ - "proc-macro2", - "syn 2.0.119", + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "libcrux-traits", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "libcrux-hmac-drbg" +version = "0.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "860985647c344b7357ce950bf8e13f076db47df36132d49b48a6fff30abe5be6" dependencies = [ - "unicode-ident", + "libcrux-hmac", + "rand", ] [[package]] -name = "quote" -version = "1.0.47" +name = "libcrux-intrinsics" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "98a0c574d4eb81d0814bc2b91e4a433d7e0b35851b1d62eb5dd95c064f1f76d0" dependencies = [ - "proc-macro2", + "core-models", + "hax-lib", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "libcrux-kem" +version = "0.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "541a7377fb35060892e0620982e224e47419f10da8c212453bf642dafe529691" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand", +] [[package]] -name = "rand_core" -version = "0.10.0" +name = "libcrux-macros" +version = "0.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" +dependencies = [ + "quote", + "syn 2.0.119", +] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "libcrux-ml-kem" +version = "0.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +checksum = "1d8160f7d64fd2716b4fd05cc886a042f8dcda18d9206c0d506e2c67bdf97daa" dependencies = [ - "semver", + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand", + "tls_codec", ] [[package]] -name = "semver" -version = "1.0.28" +name = "libcrux-p256" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +checksum = "3400732702d578be622257b98cec85e9b0cd34a67f1f06d3ebe9ae34963cdf02" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] [[package]] -name = "serde" -version = "1.0.229" +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a9144949845813a0b8787d08cbba47baabe59c83f3043e558e6e92385c40cc" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79054fc9037cb70d6a546cf094cea7d7df06af5e49d230ba24103d27ccc886f1" +dependencies = [ + "crabgrind", + "hax-lib", +] + +[[package]] +name = "libcrux-sha2" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e46e1be0b77098cc6ce82137864936ecad3a1b9fd4303dd51202ca140dd1e" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] + +[[package]] +name = "libcrux-sha3" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c09f5c39afae0528e1f70a3c1e3c6ee649ec1f19dda26fc9b6ea91108fb879ef" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", +] + +[[package]] +name = "libcrux-traits" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fa7a21e8c2e8baa8b40f0b176740f2ca4baadd79d1b00e48d6b1e363c42085a" +dependencies = [ + "libcrux-secrets", + "rand", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "ml-dsa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f6d49c3f3f0e10e18ca975ffda66f8da0ec36521c1d911adcbc521bf0743f5c" +dependencies = [ + "const-oid 0.10.3", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", + "zeroize", +] + +[[package]] +name = "ml-kem" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84ca78b66f46050d49fa21693ae8adab2081d8101b65d8067316b6b70b64a0d1" +dependencies = [ + "hybrid-array", + "kem", + "module-lattice", + "pkcs8 0.11.0", + "rand_core 0.10.0", + "sha3", + "shake", + "zeroize", +] + +[[package]] +name = "module-lattice" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4a1e412c8ee83a1c678f427806219c49c96b2511b9f07497514f0e43fb26155" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", + "zeroize", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openmls" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce4173f914cbc2dd28d9499f19e92f3e1bfebd9eea1607889924a77ca63d3ffc" +dependencies = [ + "log", + "openmls_basic_credential", + "openmls_libcrux_crypto", + "openmls_memory_storage", + "openmls_rust_crypto", + "openmls_serialization_helpers", + "openmls_sqlite_storage", + "openmls_test", + "openmls_traits", + "rayon", + "serde", + "serde_bytes", + "thiserror", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_basic_credential" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd3f0c3422e7c7a8496f042b547b0c28d0793f8ba97feb401966e58196a1e40" +dependencies = [ + "ed25519-dalek 2.2.0", + "ml-dsa", + "openmls_traits", + "p256", + "p384", + "rand_core 0.6.4", + "serde", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_libcrux_crypto" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41e6367fb30f91f21e4d30f4f58a8d3b41f96f55c3e4b5acfa1d6d18c9dd4855" +dependencies = [ + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-libcrux", + "libcrux-aead", + "libcrux-ed25519", + "libcrux-hkdf", + "libcrux-hmac", + "libcrux-hmac-drbg", + "libcrux-sha2", + "libcrux-traits", + "openmls_memory_storage", + "openmls_traits", + "rand", + "tls_codec", +] + +[[package]] +name = "openmls_memory_storage" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce927945a16eaa19302ed5ccef052f01ba68f2ba7ae67e4ac4ff2fc661445364" +dependencies = [ + "hex", + "log", + "openmls_traits", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "openmls_rust_crypto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bf3fe824ead6e81d22f49a9cd7feffca3494979fac14c1248e19516e3625f1" +dependencies = [ + "aes-gcm 0.10.3", + "chacha20poly1305", + "ed25519-dalek 2.2.0", + "hkdf 0.13.0", + "hmac 0.13.0", + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-rust-crypto", + "ml-dsa", + "openmls_memory_storage", + "openmls_traits", + "p256", + "p384", + "rand_chacha 0.3.1", + "rand_core 0.10.0", + "rand_core 0.6.4", + "sha2 0.11.1", + "thiserror", + "tls_codec", +] + +[[package]] +name = "openmls_serialization_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2cf2193541e83ce1a89aaa44c2fd4783abd54853823075a32b60ef032284c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror", +] + +[[package]] +name = "openmls_sqlite_storage" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a48acaffbed1bbed61c5030193e374ff4914331ed5f4a604bd5a4692c0a713" +dependencies = [ + "openmls_traits", + "refinery", + "rusqlite", + "serde", +] + +[[package]] +name = "openmls_test" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b4f925203b593f1ac9b5155d71f74355ef6a3ef34a081a4749bbc92288c761" +dependencies = [ + "openmls_libcrux_crypto", + "openmls_rust_crypto", + "openmls_sqlite_storage", + "openmls_traits", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openmls_traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac13edad8698eb1917cbc1dab99591fa1594d84320904fe3fc5121854f95831c" +dependencies = [ + "serde", + "tls_codec", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.3", + "spki 0.8.1", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash 0.6.1", + "zeroize", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.1", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.0", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "refinery" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2a344cdb48871e27addeafbbaffab8828cc12cec2b9041119e9bea0c0f551a" +dependencies = [ + "refinery-core", + "refinery-macros", +] + +[[package]] +name = "refinery-core" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eeafd893124f29183dd6afa9137a27e7bef59250223b8b660005279c60aea4" +dependencies = [ + "async-trait", + "cfg-if", + "log", + "regex", + "rusqlite", + "serde", + "siphasher", + "thiserror", + "time", + "toml", + "url", + "walkdir", +] + +[[package]] +name = "refinery-macros" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90cea6d11a9a4e8a85a884b6305461004101b28ca65dd35ec028e009a898e16" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "refinery-core", + "regex", + "syn 2.0.119", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "serde_core", + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c187e0a369471c550af028cba56d4f4fa2a489ad28151ae95a4d340a2a8b782" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak", + "sponge-cursor", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.0", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der 0.8.3", +] + +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +dependencies = [ + "zeroize", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "syn" +version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" dependencies = [ - "serde_derive", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "synstructure" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", @@ -622,119 +2377,127 @@ dependencies = [ ] [[package]] -name = "serde_json" -version = "1.0.154" +name = "thiserror" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", + "thiserror-impl", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "thiserror-impl" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "proc-macro2", + "quote", + "syn 3.0.10", ] [[package]] -name = "sha2" -version = "0.11.1" +name = "time" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", ] [[package]] -name = "sha3" -version = "0.12.0" +name = "time-core" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" -dependencies = [ - "digest 0.11.3", - "keccak", - "sponge-cursor", -] +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] -name = "shake" -version = "0.1.0" +name = "time-macros" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ - "digest 0.11.3", - "keccak", - "sponge-cursor", + "num-conv", + "time-core", ] [[package]] -name = "signature" -version = "3.0.0" +name = "tinystr" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ - "digest 0.11.3", - "rand_core", + "displaydoc", + "zerovec", ] [[package]] -name = "spki" -version = "0.8.1" +name = "tls_codec" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +checksum = "18cc98286004cea38f717e2b03d990fc774fbfd38a82720de40e5c94365067c8" dependencies = [ - "base64ct", - "der", + "serde", + "serde_bytes", + "tls_codec_derive", + "zeroize", ] [[package]] -name = "sponge-cursor" -version = "0.1.0" +name = "tls_codec_derive" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" +checksum = "674f41dd95f76cdeb005c83f9444e20cf43daebef37cde9e49a9ca6f4e87b423" dependencies = [ - "zeroize", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "subtle" -version = "2.6.1" +name = "toml" +version = "1.2.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "d5755c51863cdac711f1956baf8eb9588e060755f7d3c4c979e70ea1e1f32cd7" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] [[package]] -name = "syn" -version = "2.0.119" +name = "toml_datetime" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +checksum = "173f25c883cd2176a2d8212155638c0a60d484a9d84546d270866f40f56b6e49" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "serde_core", ] [[package]] -name = "syn" -version = "3.0.10" +name = "toml_parser" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae9ccbea3a7b0b09443036490c8f34a8b1b760fd345e5c0dae0aac718faea79" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", + "winnow", ] +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + [[package]] name = "typenum" version = "1.20.1" @@ -753,6 +2516,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "universal-hash" version = "0.6.1" @@ -763,12 +2536,72 @@ dependencies = [ "ctutils", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bed99139f7dac9b5cfe5f17be4d66dc7079a34dee1d74bcc77217119240e267c" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -787,6 +2620,50 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.10", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -821,6 +2698,36 @@ dependencies = [ "semver", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -915,22 +2822,117 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x-wing" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01576e56599d01d75c5efcd5b30fa5de29e776f5a225e43fbf8c92c7302d4c8b" +dependencies = [ + "kem", + "ml-kem", + "sha3", + "shake", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", +] + [[package]] name = "x25519-dalek" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ - "curve25519-dalek", - "rand_core", + "curve25519-dalek 5.0.0", + "rand_core 0.10.0", "zeroize", ] +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", + "synstructure", +] + [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -946,6 +2948,39 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.10", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/packages/fireplace_native/rust/Cargo.toml b/packages/fireplace_native/rust/Cargo.toml index 91db7c0..ef2299f 100644 --- a/packages/fireplace_native/rust/Cargo.toml +++ b/packages/fireplace_native/rust/Cargo.toml @@ -12,7 +12,9 @@ crate-type = ["cdylib"] # Select this dependency directly with `cargo build --package fireplace-crypto` # so the host library exports the core's C ABI without another Rust wrapper. [dependencies] -fireplace-crypto = "=0.1.1" +# Group chats (decision 0050): the groups branch of fireplace-crypto until it is +# released; then back to an exact crates.io version. +fireplace-crypto = { git = "https://github.com/fireplacechat/fireplace-crypto", rev = "276eaacce7764f2c4914eb975040d9347db18079", features = ["groups"] } [features] # Only the separate host comparison build enables this; no app build does. diff --git a/scripts/render/render_groups_test.dart b/scripts/render/render_groups_test.dart new file mode 100644 index 0000000..66b2186 --- /dev/null +++ b/scripts/render/render_groups_test.dart @@ -0,0 +1,218 @@ +// Renders the group screens (decision 0050) into build/renders/groups/, for +// review and for pull requests: +// +// bash scripts/build_crypto_host.sh +// TZ=UTC flutter test scripts/render/render_groups_test.dart +// +// Unlike the catalog in render_screens_test.dart, these use real group +// services over the Rust MLS core (there is no fake of MLS), so group ids and +// times differ between runs and the images are not byte-identical. They are +// widget renders, not device screenshots, and the names are fictional. +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:fireplace/src/app/chat_color_preference.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/chat_list/chat_list_screen.dart'; +import 'package:fireplace/src/view/groups/group_chat_screen.dart'; +import 'package:fireplace/src/view/groups/group_info_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../test/support/fake_group_server.dart'; +import '../../test/support/group_phones.dart'; +import '../../test/support/ui_fixture.dart'; +import 'render_screens_test.dart' show loadFonts; + +void main() { + late Directory root; + late FakeFirebaseFirestore db; + late FakeGroupServer server; + late Phone alice, bob, carol, dana; + late String mine, theirs; + + /// A few people, two groups and some conversation. Alice is this phone. + Future story() async { + root = await Directory.systemTemp.createTemp('group_renders'); + db = FakeFirebaseFirestore(); + server = FakeGroupServer(db); + alice = await Phone.create(db, 'alice', root); + bob = await Phone.create(db, 'bob_jones', root); + carol = await Phone.create(db, 'carol', root); + dana = await Phone.create(db, 'dana', root); + for (final p in [alice, bob, carol, dana]) { + server.watchAccount(p.uid); + } + bob.service.ownColor = 'plum'; + carol.service.ownColor = 'forest'; + + Future said(Phone from, String gid, String text) async { + await from.service.send(gid, text); + await until( + () async => (await texts(alice, gid)).contains(text), + what: text, + ); + } + + mine = await alice.service.create('Book club', ['bob_jones', 'carol']); + for (final p in [bob, carol]) { + await until( + () async => p.service.invitations.any((i) => i.gid == mine), + what: 'invitation', + ); + await p.service.accept(mine); + await until( + () async => alice.service.colorsOf(mine).containsKey(p.uid), + what: 'colour of ${p.uid}', + ); + } + await alice.service.send(mine, 'Shall we start the new one this week?'); + await said(bob, mine, 'Yes. I have the first three chapters done'); + await said(bob, mine, 'It gets going after the second'); + await said(carol, mine, 'Thursday works for me'); + await alice.service.send(mine, 'Thursday at 7, then'); + await alice.service.setAdmin(mine, 'bob_jones', admin: true); + await alice.service.invite(mine, 'dana'); + await until( + () async => alice.service.invitedTo(mine).containsKey('dana'), + what: 'Dana invited', + ); + await said(carol, mine, 'I will bring the tea'); + + theirs = await bob.service.create('Hill walkers', ['alice']); + await until( + () async => alice.service.invitations.any((i) => i.gid == theirs), + what: 'invitation', + ); + await alice.service.accept(theirs); + await until( + () async => bob.service.groups[theirs]!.members.length == 2, + what: 'joined', + ); + await said(bob, theirs, 'Welcome. Saturday is the ridge walk'); + } + + Future settle(WidgetTester t, [int rounds = 12]) async { + for (var i = 0; i < rounds; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await t.pump(const Duration(milliseconds: 25)); + } + } + + for (final brightness in Brightness.values) { + testWidgets('group screens, ${brightness.name}', (t) async { + await t.runAsync(loadFonts); + await t.runAsync(story); + final previousShadows = debugDisableShadows; + debugDisableShadows = false; + addTearDown(() => debugDisableShadows = previousShadows); + t.view.physicalSize = const Size(390, 844); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + + Future render( + String name, + Widget screen, { + Future Function()? act, + }) async { + final fixture = UiFixture(); + await fixture.seed(); + final boundary = GlobalKey(); + await t.pumpWidget( + ProviderScope( + key: UniqueKey(), + overrides: [ + ...fixture.overrides, + groupServiceProvider.overrideWithValue(alice.service), + groupContactsProvider.overrideWithValue(const ['bob_jones']), + ], + child: RepaintBoundary( + key: boundary, + child: Consumer( + builder: (context, ref, _) { + final theme = fireplaceTheme( + brightness, + accent: ref.watch( + chatBubbleColorsProvider.select((c) => c.outgoing), + ), + ); + return MaterialApp( + debugShowCheckedModeBanner: false, + theme: theme.copyWith( + textTheme: theme.textTheme.apply(fontFamily: 'Roboto'), + ), + home: screen, + ); + }, + ), + ), + ), + ); + await settle(t); + await act?.call(); + await settle(t); + final renderer = + boundary.currentContext!.findRenderObject()! + as RenderRepaintBoundary; + final png = await t.runAsync(() async { + final image = await renderer.toImage(pixelRatio: 2); + try { + return (await image.toByteData(format: ui.ImageByteFormat.png))! + .buffer + .asUint8List(); + } finally { + image.dispose(); + } + }); + File('build/renders/groups/${brightness.name}-$name.png') + ..createSync(recursive: true) + ..writeAsBytesSync(png!); + } + + await render('list', const ChatListScreen()); + await render('conversation', GroupChatScreen(gid: mine)); + await render( + 'conversation-menu', + GroupChatScreen(gid: mine), + act: () async { + await t.longPress(find.text('Thursday at 7, then')); + await t.pump(const Duration(milliseconds: 600)); + }, + ); + await render( + 'conversation-editing', + GroupChatScreen(gid: mine), + act: () async { + await t.longPress(find.text('Thursday at 7, then')); + await t.pump(const Duration(milliseconds: 600)); + await t.tap(find.byKey(const ValueKey('messageAction-edit'))); + // The menu closes, then the composer takes the text. + for (var i = 0; i < 10; i++) { + await t.pump(const Duration(milliseconds: 100)); + } + }, + ); + await render('joined-later', GroupChatScreen(gid: theirs)); + await render('info', GroupInfoScreen(gid: mine)); + + await t.pumpWidget(const SizedBox()); + await t.runAsync(() async { + for (final p in [alice, bob, carol, dana]) { + await p.service.close(); + } + await server.close(); + await root.delete(recursive: true); + }); + // The test binding checks this is back before the body ends. + debugDisableShadows = previousShadows; + expect(GroupLimits.maxMembers, 16); + }); + } +} diff --git a/test/app/tap_outside_test.dart b/test/app/tap_outside_test.dart index 7917c38..84c4c91 100644 --- a/test/app/tap_outside_test.dart +++ b/test/app/tap_outside_test.dart @@ -5,7 +5,7 @@ import 'package:fireplace/src/view/account/account_deletion_screens.dart'; import 'package:fireplace/src/view/auth/auth_screen.dart'; import 'package:fireplace/src/view/chat/chat_screen.dart'; import 'package:fireplace/src/view/chat_list/chat_list_screen.dart'; -import 'package:fireplace/src/view/chat_list/start_chat_dialog.dart'; +import 'package:fireplace/src/view/chat_list/new_chat_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -299,25 +299,22 @@ void main() { 'dialog fields dismiss on outside touches without closing dialog', (tester) async { await pumpApp(tester); - unawaited( - showDialog( - context: tester.element(find.byType(AuthScreen)), - builder: (_) => const StartChatDialog(), - ), - ); + unawaited(NewChatSheet.show(tester.element(find.byType(AuthScreen)))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('peerUsername'))); await tester.pumpAndSettle(); expect(field(tester, 'peerUsername').focusNode.hasFocus, isTrue); - await tester.tap(find.text('Start a chat')); + await tester.tap(find.text('New chat')); await tester.pumpAndSettle(); expect(field(tester, 'peerUsername').focusNode.hasFocus, isFalse); expect(tester.testTextInput.isVisible, isFalse); - expect(find.byType(StartChatDialog), findsOneWidget); + expect(find.byType(NewChatSheet), findsOneWidget); await tester.tap(find.byKey(const Key('peerUsername'))); await tester.pumpAndSettle(); - await tester.tap(find.text('Cancel')); + await tester.tap(find.byKey(const Key('cancelNewChat'))); await tester.pumpAndSettle(); - expect(find.byType(StartChatDialog), findsNothing); + expect(find.byType(NewChatSheet), findsNothing); expect(tester.testTextInput.isVisible, isFalse); }, variant: platforms, diff --git a/test/model/groups/group_service_test.dart b/test/model/groups/group_service_test.dart new file mode 100644 index 0000000..b4fc505 --- /dev/null +++ b/test/model/groups/group_service_test.dart @@ -0,0 +1,638 @@ +// Group chats end to end on two devices (decision 0050): the real Rust MLS core, +// a fake Firestore, and a fake of the backend's request function. +import 'dart:io'; + +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:fireplace/fireplace_services.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/model/groups/group_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fake_group_server.dart'; +import '../../support/group_phones.dart'; + +void main() { + late Directory root; + late FakeFirebaseFirestore db; + late FakeGroupServer server; + late Phone alice, bob; + + setUp(() async { + root = await Directory.systemTemp.createTemp('groups'); + db = FakeFirebaseFirestore(); + server = FakeGroupServer(db); + alice = await Phone.create(db, 'alice', root); + bob = await Phone.create(db, 'bob', root); + server + ..watchAccount('alice') + ..watchAccount('bob'); + }); + tearDown(() async { + await alice.service.close(); + await bob.service.close(); + await server.close(); + await root.delete(recursive: true); + }); + + /// Alice creates "Book club" and invites Bob, who joins. + Future bookClub() async { + final gid = await alice.service.create('Book club', ['bob']); + await until( + () async => bob.service.invitations.any((i) => i.gid == gid), + what: 'invitation', + ); + await bob.service.accept(gid); + await until( + () async => bob.service.groups.containsKey(gid), + what: 'Bob joins', + ); + await until( + () async => alice.service.groups[gid]!.members.length == 2, + what: 'Alice sees Bob', + ); + return gid; + } + + test('a device publishes ten key packages when it starts', () async { + final devices = await db.collection('users/bob/devices').get(); + final packages = await db + .collection('users/bob/devices/${devices.docs.first.id}/mlsPackages') + .get(); + expect(packages.docs, hasLength(GroupLimits.keyPackagesPerDevice)); + }); + + test( + 'create, invite, join: both see the same group, name and roles', + () async { + final gid = await bookClub(); + for (final p in [alice, bob]) { + final info = p.service.groups[gid]!; + expect(info.name, 'Book club'); + expect(info.members.map((m) => m.uid).toSet(), {'alice', 'bob'}); + expect(info.isAdmin('alice'), isTrue); + expect(info.isAdmin('bob'), isFalse); + } + expect(alice.service.groups[gid]!.epoch, bob.service.groups[gid]!.epoch); + expect(server.processed, containsAll(['create', 'invite', 'accept'])); + await until( + () async => bob.service.invitations.isEmpty, + what: 'the invitation goes', + ); + }, + ); + + test( + 'messages reach the other member, never twice, with the right sender', + () async { + final gid = await bookClub(); + await alice.service.send(gid, 'Anyone bringing snacks?'); + await until( + () async => (await texts(bob, gid)).contains('Anyone bringing snacks?'), + what: 'Bob receives', + ); + await bob.service.send(gid, 'I can do chips'); + await until( + () async => (await texts(alice, gid)).contains('I can do chips'), + what: 'Alice receives', + ); + final bobs = await bob.messages.watch(groupChatId(gid)).first; + final fromAlice = bobs.firstWhere( + (m) => m.body == 'Anyone bringing snacks?', + ); + expect(fromAlice.senderUid, 'alice'); + expect(fromAlice.outgoing, isFalse); + final own = bobs.firstWhere((m) => m.body == 'I can do chips'); + expect(own.outgoing, isTrue); + expect(own.status, MessageStatus.ok); + expect( + (await texts(alice, gid)).where((t) => t == 'I can do chips'), + hasLength(1), + ); + }, + ); + + test('members share their colour inside the group', () async { + alice.service.ownColor = 'plum'; + final gid = await bookClub(); + await alice.service.shareColor(); + await until( + () async => bob.service.colorsOf(gid)['alice'] == 'plum', + what: 'colour', + ); + await until( + () async => alice.service.colorsOf(gid)['bob'] == 'ember', + what: "Bob's colour", + ); + }); + + test('an administrator renames the group for everyone', () async { + final gid = await bookClub(); + await alice.service.rename(gid, 'Tea club'); + for (final p in [alice, bob]) { + await until( + () async => p.service.groups[gid]?.name == 'Tea club', + what: 'rename on ${p.uid}', + ); + } + }); + + test("a member's rename is refused by the others", () async { + final gid = await bookClub(); + final epoch = alice.service.groups[gid]!.epoch; + await bob.service.rename(gid, 'Bob club'); + // Bob's Commit took the slot, but Alice will not apply it. + await Future.delayed(const Duration(seconds: 1)); + expect(alice.service.groups[gid]!.name, 'Book club'); + expect(alice.service.groups[gid]!.epoch, epoch); + }); + + test( + 'leaving stops the group for the leaver and a member finalises it', + () async { + final gid = await bookClub(); + final epoch = alice.service.groups[gid]!.epoch; + await bob.service.leave(gid); + expect(bob.service.groups.containsKey(gid), isFalse); + await until( + () async => + (await db.doc('groups/$gid').get()).data()!['fence'].isEmpty && + alice.service.groups[gid]!.epoch == epoch + 1, + what: 'finalised', + ); + expect(alice.service.groups[gid]!.members.map((m) => m.uid), ['alice']); + // Alice keeps chatting in the (now one-person) group. + await alice.service.send(gid, 'still here'); + }, + ); + + test( + 'only the author edits or deletes for everyone; anyone reacts', + () async { + final gid = await bookClub(); + await alice.service.send(gid, 'See you at 7'); + Future bobsCopy() async => + (await bob.messages.watch(groupChatId(gid)).first) + .where((m) => m.senderUid == 'alice' && m.visible) + .firstOrNull; + await until(() async => (await bobsCopy()) != null, what: 'delivered'); + final id = (await bobsCopy())!.id; + + await alice.service.edit(gid, id, 'See you at 8'); + await until( + () async => (await bobsCopy())?.body == 'See you at 8', + what: 'edit', + ); + // Bob cannot edit or delete Alice's message. + await expectLater( + bob.service.edit(gid, id, 'hacked'), + throwsA(isA()), + ); + await expectLater( + bob.service.deleteForEveryone(gid, id), + throwsA(isA()), + ); + + await bob.service.react(gid, id, '👍'); + await until( + () async => + (await alice.messages.get( + groupChatId(gid), + id, + ))?.reactions['bob']?.emoji == + '👍', + what: 'reaction', + ); + + await alice.service.deleteForEveryone(gid, id); + await until( + () async => + (await bob.messages.get(groupChatId(gid), id))?.remotelyDeleted == + true, + what: 'deleted for everyone', + ); + }, + ); + + test('a member deletes a message from their own phone only', () async { + final gid = await bookClub(); + await alice.service.send(gid, 'only mine to hide'); + await until( + () async => (await texts(bob, gid)).contains('only mine to hide'), + what: 'delivered', + ); + final id = (await bob.messages.watch(groupChatId(gid)).first) + .firstWhere((m) => m.body == 'only mine to hide') + .id; + await bob.service.deleteLocally(gid, id); + expect(await texts(bob, gid), isNot(contains('only mine to hide'))); + expect(await texts(alice, gid), contains('only mine to hide')); + }); + + test( + 'any member invites, and the person joins from their invitation', + () async { + final gid = await bookClub(); + final carol = await Phone.create(db, 'carol', root); + server.watchAccount('carol'); + await bob.service.invite(gid, 'carol'); + await until( + () async => carol.service.invitations.isNotEmpty, + what: 'invitation', + ); + await carol.service.accept(gid); + await until( + () async => carol.service.groups.containsKey(gid), + what: 'Carol joins', + ); + await until( + () async => alice.service.groups[gid]!.members.length == 3, + what: 'Alice sees Carol', + ); + await carol.service.send(gid, 'hello from Carol'); + await until( + () async => (await texts(alice, gid)).contains('hello from Carol'), + what: 'Carol speaks', + ); + await carol.service.close(); + }, + ); + + test('the group ends when its last member leaves', () async { + final gid = await bookClub(); + await bob.service.leave(gid); + await alice.service.leave(gid); + expect(alice.service.groups, isEmpty); + await until( + () async => !(await db.doc('groups/$gid').get()).exists, + what: 'group deleted', + ); + }); + + test('a declined invitation goes away and adds nobody', () async { + final gid = await alice.service.create('Book club', ['bob']); + await until( + () async => bob.service.invitations.isNotEmpty, + what: 'invitation', + ); + // Invited: Bob's device is an MLS leaf, but not in the server's roster. + expect(alice.service.groups[gid]!.members, hasLength(2)); + await bob.service.decline(gid); + await until(() async => bob.service.invitations.isEmpty, what: 'declined'); + expect(bob.service.groups, isEmpty); + // Alice's phone removes the leaf; nothing says Bob declined. + await until( + () async => alice.service.groups[gid]!.members.length == 1, + what: 'the leaf removed', + ); + expect(alice.service.invitedTo(gid), isEmpty); + final lines = [ + for (final m in await alice.messages.watch(groupChatId(gid)).first) + if (m.status == MessageStatus.notice) GroupNotice.decode(m.body)!.kind, + ]; + expect(lines, [GroupNoticeKind.added, GroupNoticeKind.gone]); + }); + + test('a device restarted from its saved records carries on', () async { + final gid = await bookClub(); + await bob.service.close(); + final devices = await db.collection('users/bob/devices').get(); + final device = await KeyService(db, bob.secrets).ensureDevice('bob'); + expect(device.keys.deviceId, devices.docs.first.id); + final again = GroupService( + db: db, + uid: 'bob', + device: device, + secrets: bob.secrets, + historyDir: Directory('${root.path}/bob'), + messages: bob.messages, + ); + await again.start(); + expect(again.groups[gid]!.name, 'Book club'); + await alice.service.send(gid, 'after a restart'); + await until( + () async => (await texts(bob, gid)).contains('after a restart'), + what: 'after restart', + ); + await again.close(); + bob = await Phone.create( + FakeFirebaseFirestore(), + 'bob2', + root, + ); // for tearDown + }); + + // ------------------------------------------- invitations, colours, lines, roles + + /// The lines in a phone's history about changes to the group. + Future> notices(Phone p, String gid) async => [ + for (final m in await p.messages.watch(groupChatId(gid)).first) + if (m.status == MessageStatus.notice) GroupNotice.decode(m.body)!, + ]; + Future hasNotice( + Phone p, + String gid, + GroupNoticeKind kind, { + String? by, + String? who, + }) async => (await notices( + p, + gid, + )).any((n) => n.kind == kind && n.by == by && n.who == who); + + test('joining needs nobody else: the inviter can be offline', () async { + final gid = await alice.service.create('Book club', ['bob']); + await until( + () async => bob.service.invitations.any((i) => i.gid == gid), + what: 'invitation', + ); + // The invitation already put Bob's device in the MLS group. + expect(alice.service.invitedTo(gid), {'bob': 'alice'}); + await alice.service.send(gid, 'sent before Bob joined'); + await alice.service.close(); + + await bob.service.accept(gid); + // In the chat as soon as Join returns, with no other phone running. + expect(bob.service.groups[gid]?.name, 'Book club'); + expect(server.processed, isNot(contains('add'))); + await until( + () async => + ((await db.doc('groups/$gid').get()).data()!['members'] as Map) + .containsKey('bob'), + what: 'the roster', + ); + await bob.service.send(gid, 'I am in'); + expect(await texts(bob, gid), contains('I am in')); + // Nothing from before Bob joined is shown, and the screen can say so. + expect(await texts(bob, gid), isNot(contains('sent before Bob joined'))); + expect(bob.service.joinedLater(gid), isTrue); + alice = await Phone.create(FakeFirebaseFirestore(), 'alice2', root); + }); + + test('the person who made the group did not join later', () async { + final gid = await bookClub(); + expect(alice.service.joinedLater(gid), isFalse); + expect(bob.service.joinedLater(gid), isTrue); + }); + + test('someone with no key package left cannot be invited yet', () async { + final gid = await alice.service.create('Book club', []); + final devices = await db.collection('users/bob/devices').get(); + for (final p + in (await devices.docs.first.reference.collection('mlsPackages').get()) + .docs) { + await p.reference.delete(); + } + await expectLater( + alice.service.invite(gid, 'bob'), + throwsA( + isA().having((e) => e.reason, 'reason', 'packages'), + ), + ); + expect(alice.service.groups[gid]!.members, hasLength(1)); + expect(bob.service.invitations, isEmpty); + }); + + test( + 'an invitation nobody answers in seven days is ended by a member', + () async { + var now = DateTime.now(); + final carol = await Phone.create(db, 'carol', root, clock: () => now); + server + ..watchAccount('carol') + ..now = () => now; + final gid = await carol.service.create('Book club', ['bob']); + await until( + () async => bob.service.invitations.isNotEmpty, + what: 'invitation', + ); + now = now.add(const Duration(days: 8)); + // Any change to the group makes a member's phone look at it again. + await carol.service.rename(gid, 'Tea club'); + await until(() async { + final g = (await db.doc('groups/$gid').get()).data()!; + return (g['invited'] as Map).isEmpty && + (g['fence'] as List).isEmpty && + carol.service.groups[gid]!.members.length == 1; + }, what: 'expired and removed'); + await expectLater( + bob.service.accept(gid), + throwsA(anyOf(isA(), isA())), + ); + expect(bob.service.groups, isEmpty); + await carol.service.close(); + }, + ); + + test('someone who joins later gets every member\'s colour', () async { + alice.service.ownColor = 'plum'; + bob.service.ownColor = 'moss'; + final gid = await bookClub(); + final carol = await Phone.create(db, 'carol', root); + carol.service.ownColor = 'sky'; + server.watchAccount('carol'); + await alice.service.invite(gid, 'carol'); + // More happens in the group before Carol answers. + await alice.service.rename(gid, 'Tea club'); + await until( + () async => carol.service.invitations.isNotEmpty, + what: 'invitation', + ); + await carol.service.accept(gid); + await until( + () async => + carol.service.colorsOf(gid)['alice'] == 'plum' && + carol.service.colorsOf(gid)['bob'] == 'moss', + what: "Carol has both colours", + ); + for (final p in [alice, bob]) { + await until( + () async => p.service.colorsOf(gid)['carol'] == 'sky', + what: "Carol's colour on ${p.uid}", + ); + } + expect(carol.service.groups[gid]!.name, 'Tea club'); + await carol.service.close(); + }); + + test("a phone that lacks a member's colour asks for it once", () async { + alice.service.ownColor = 'plum'; + final gid = await bookClub(); + await until( + () async => bob.service.colorsOf(gid)['alice'] == 'plum', + what: 'the colour', + ); + // Bob's phone as it was before members answered a newcomer: it joined, and + // nobody's colour ever arrived. + await bob.service.close(); + await bob.secrets.delete('groupcolors:bob'); + final again = GroupService( + db: db, + uid: 'bob', + device: await KeyService(db, bob.secrets).ensureDevice('bob'), + secrets: bob.secrets, + historyDir: Directory('${root.path}/bob'), + messages: bob.messages, + colourCatchUp: const Duration(milliseconds: 100), + ); + await again.start(); + expect(again.colorsOf(gid), isEmpty); + await until( + () async => again.colorsOf(gid)['alice'] == 'plum', + what: 'the colour, asked for', + ); + await again.close(); + bob = await Phone.create(FakeFirebaseFirestore(), 'bob2', root); + }); + + test('changes to the group are written into the history as lines', () async { + final gid = await bookClub(); + for (final p in [alice, bob]) { + // Bob's own phone joined at the epoch after the Add, so only Alice's + // has the line for it. + if (p == alice) { + expect( + await hasNotice( + p, + gid, + GroupNoticeKind.added, + by: 'alice', + who: 'bob', + ), + isTrue, + ); + } + } + await alice.service.rename(gid, 'Tea club'); + await alice.service.setAdmin(gid, 'bob', admin: true); + for (final p in [alice, bob]) { + await until( + () async => + await hasNotice(p, gid, GroupNoticeKind.renamed, by: 'alice') && + await hasNotice( + p, + gid, + GroupNoticeKind.madeAdmin, + by: 'alice', + who: 'bob', + ), + what: 'rename and role lines on ${p.uid}', + ); + } + await bob.service.setAdmin(gid, 'alice', admin: false); + await until( + () async => await hasNotice( + alice, + gid, + GroupNoticeKind.removedAdmin, + by: 'bob', + who: 'alice', + ), + what: 'the role line', + ); + // Lines are not messages: they never count as text from a person. + final line = (await alice.messages.watch(groupChatId(gid)).first) + .firstWhere((m) => m.status == MessageStatus.notice); + expect(GroupNotice.decode(line.body), isNotNull); + expect( + const GroupNotice( + GroupNoticeKind.added, + by: 'alice', + who: 'bob', + ).text((uid) => uid == 'alice' ? 'You' : uid), + 'You added bob', + ); + }); + + test('a line says whether someone left or was removed', () async { + final gid = await bookClub(); + final carol = await Phone.create(db, 'carol', root); + server.watchAccount('carol'); + await alice.service.invite(gid, 'carol'); + await until( + () async => carol.service.invitations.isNotEmpty, + what: 'invitation', + ); + await carol.service.accept(gid); + await until( + () async => alice.service.groups[gid]!.members.length == 3, + what: 'three people', + ); + await carol.service.leave(gid); + await until( + () async => + await hasNotice(alice, gid, GroupNoticeKind.left, who: 'carol'), + what: 'Carol left', + ); + await alice.service.remove(gid, 'bob'); + await until( + () async => await hasNotice( + alice, + gid, + GroupNoticeKind.removed, + by: 'alice', + who: 'bob', + ), + what: 'Bob removed', + ); + await carol.service.close(); + }); + + test('an administrator makes another; a member cannot', () async { + final gid = await bookClub(); + await expectLater( + bob.service.setAdmin(gid, 'bob', admin: true), + throwsA(isA()), + ); + await alice.service.setAdmin(gid, 'bob', admin: true); + for (final p in [alice, bob]) { + await until( + () async => p.service.groups[gid]!.isAdmin('bob'), + what: 'Bob administers on ${p.uid}', + ); + } + expect( + ((await db.doc('groups/$gid').get()).data()!['members'] + as Map)['bob']['role'], + 'admin', + ); + // The last administrator cannot stop being one. + await bob.service.setAdmin(gid, 'alice', admin: false); + await until( + () async => !bob.service.groups[gid]!.isAdmin('alice'), + what: 'Alice no longer administers', + ); + await expectLater( + bob.service.setAdmin(gid, 'bob', admin: false), + throwsA(isA()), + ); + }); + + test("a member's role change is refused by the others", () async { + final gid = await bookClub(); + final epoch = alice.service.groups[gid]!.epoch; + // Bob's phone is made to send what the app never would. + await bob.service.commitMetaUnchecked(gid, admins: ['bob']); + await Future.delayed(const Duration(seconds: 1)); + expect(alice.service.groups[gid]!.isAdmin('bob'), isFalse); + expect(alice.service.groups[gid]!.isAdmin('alice'), isTrue); + expect(alice.service.groups[gid]!.epoch, epoch); + expect( + await notices(alice, gid), + everyElement( + isA().having((n) => n.kind, 'kind', GroupNoticeKind.added), + ), + ); + }); + + test('an invited person who is removed never joins', () async { + final gid = await alice.service.create('Book club', ['bob']); + await until( + () async => bob.service.invitations.isNotEmpty, + what: 'invitation', + ); + await alice.service.remove(gid, 'bob'); + await until(() async => bob.service.invitations.isEmpty, what: 'withdrawn'); + expect(alice.service.groups[gid]!.members, hasLength(1)); + expect(alice.service.invitedTo(gid), isEmpty); + }); +} diff --git a/test/support/fake_group_server.dart b/test/support/fake_group_server.dart new file mode 100644 index 0000000..4b999c9 --- /dev/null +++ b/test/support/fake_group_server.dart @@ -0,0 +1,266 @@ +// Stands in for the server's group membership function over a +// FakeFirebaseFirestore: it completes membership requests the way the server +// does, without the checks the tests do not exercise. The security rules are +// tested with the server code. +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; + +class FakeGroupServer { + FakeGroupServer(this.db); + final FakeFirebaseFirestore db; + final _subs = >[]; + final _watched = {}; + final _done = {}; + final processed = []; + + /// The server's clock; a test moves it to let an invitation expire. + DateTime Function() now = DateTime.now; + + String _slot(int e) => '$e'.padLeft(12, '0'); + + void watchAccount(String uid) { + _subs.add( + db.collection('users/$uid/groupOps').snapshots().listen((s) { + for (final d in s.docs) { + unawaited(_handle(d.reference, d.data(), (op) => _account(uid, op))); + } + }), + ); + } + + void _watchGroup(String gid) { + if (!_watched.add(gid)) return; + _subs.add( + db.collection('groups/$gid/ops').snapshots().listen((s) { + for (final d in s.docs) { + unawaited(_handle(d.reference, d.data(), (op) => _group(gid, op))); + } + }), + ); + } + + // One request at a time, as the backend's transactions serialise them. + Future _queue = Future.value(); + + Future _handle( + DocumentReference> ref, + Map op, + Future Function(Map) run, + ) { + if (op['status'] != null || !_done.add(ref.path)) return Future.value(); + final next = _queue.then((_) => _handleNow(ref, op, run)); + _queue = next.catchError((Object _) {}); + return next; + } + + Future _handleNow( + DocumentReference> ref, + Map op, + Future Function(Map) run, + ) async { + try { + await run(op); + processed.add('${op['kind']}'); + await ref.update({ + 'status': 'done', + 'commit': FieldValue.delete(), + 'welcome': FieldValue.delete(), + }); + } on _Refused catch (e) { + processed.add('${op['kind']}:refused'); + await ref.update({'status': 'refused', 'reason': e.reason}); + } catch (e, st) { + // ignore: avoid_print + print('fake server ${op['kind']} failed: $e $st'); + rethrow; + } + } + + Future _account(String uid, Map op) async { + final gid = op['gid'] as String; + switch (op['kind']) { + case 'create': + await db.doc('groups/$gid').set({ + 'members': { + uid: {'role': 'admin', 'devices': op['devices']}, + }, + 'epoch': op['epoch'], + 'fence': [], + 'invited': {}, + }); + await db.doc('users/$uid/groupRefs/$gid').set({ + 'role': 'admin', + 'joinedEpoch': op['epoch'], + 'joinedAt': Timestamp.fromDate(now()), + }); + _watchGroup(gid); + case 'accept': + // The invitation already added the devices to MLS: joining only moves + // the account into the roster. + final group = (await db.doc('groups/$gid').get()).data(); + final invite = (group?['invited'] as Map?)?[uid] as Map?; + if (group == null || invite == null) throw _Refused('no invite'); + if ((invite['expireAt'] as Timestamp).toDate().isBefore(now())) { + throw _Refused('expired'); + } + await db.doc('groups/$gid').update({ + 'members.$uid': {'role': 'member', 'devices': invite['devices']}, + 'invited.$uid': FieldValue.delete(), + }); + await db.doc('users/$uid/groupRefs/$gid').set({ + 'role': 'member', + 'joinedEpoch': group['epoch'], + 'joinedAt': Timestamp.fromDate(now()), + }); + await db.doc('users/$uid/groupInvites/$gid').delete(); + case 'decline': + final group = (await db.doc('groups/$gid').get()).data(); + final invite = (group?['invited'] as Map?)?[uid] as Map?; + if (group != null && invite != null) { + await db.doc('groups/$gid').update({ + 'invited.$uid': FieldValue.delete(), + 'fence': [ + ...(group['fence'] as List), + {'uid': uid, 'devices': invite['devices']}, + ], + }); + } + await db.doc('users/$uid/groupInvites/$gid').delete(); + } + } + + Future _group(String gid, Map op) async { + final groupRef = db.doc('groups/$gid'); + final group = (await groupRef.get()).data(); + if (group == null) throw _Refused('no group'); + final members = Map.from(group['members'] as Map); + final invited = Map.from( + (group['invited'] as Map?) ?? const {}, + ); + final by = op['by'] as String; + if (!members.containsKey(by)) throw _Refused('not a member'); + final epoch = group['epoch'] as int; + Future commit(String kind) async { + if (op['epoch'] != epoch) throw _Refused('epoch'); + final slot = db.doc('groups/$gid/commits/${_slot(epoch)}'); + if ((await slot.get()).exists) throw _Refused('epoch'); + await slot.set({ + 'e': epoch, + 'by': by, + 'device': op['device'] ?? '', + 'kind': kind, + 'ct': op['commit'], + }); + } + + switch (op['kind']) { + case 'invite': + // Any member invites (the backend also requires a 1:1 contact). The + // request carries the Add Commit, the Welcome and the key packages. + final target = op['uid'] as String; + if (members.containsKey(target)) throw _Refused('account'); + if (invited.containsKey(target)) throw _Refused('already invited'); + final devices = (op['devices'] as List?)?.cast() ?? const []; + if (devices.isEmpty) throw _Refused('devices'); + await commit('membership'); + await db.doc('groups/$gid/welcomes/${target}_${_slot(epoch + 1)}').set({ + 'uid': target, + 'epoch': epoch + 1, + 'ct': op['welcome'], + }); + for (final p in (op['packages'] as List).cast()) { + await db + .doc( + 'users/$target/devices/${p['device']}/mlsPackages/${p['slot']}', + ) + .delete(); + } + final expireAt = Timestamp.fromDate(now().add(const Duration(days: 7))); + await groupRef.update({ + 'invited.$target': { + 'by': by, + 'devices': devices, + 'expireAt': expireAt, + }, + 'epoch': epoch + 1, + }); + await db.doc('users/$target/groupInvites/$gid').set({ + 'by': by, + 'at': Timestamp.fromDate(now()), + 'expireAt': expireAt, + }); + case 'expire': + final target = op['uid'] as String; + final invite = invited[target] as Map?; + if (invite == null) throw _Refused('no invite'); + if (!(invite['expireAt'] as Timestamp).toDate().isBefore(now())) { + throw _Refused('not expired'); + } + await groupRef.update({ + 'invited.$target': FieldValue.delete(), + 'fence': [ + ...(group['fence'] as List), + {'uid': target, 'devices': invite['devices']}, + ], + }); + await db.doc('users/$target/groupInvites/$gid').delete(); + case 'roles': + if (members[by]['role'] != 'admin') { + throw _Refused('not an administrator'); + } + final admins = (op['admins'] as List).cast(); + if (admins.isEmpty || !admins.every(members.containsKey)) { + throw _Refused('admins'); + } + await commit('meta'); + await groupRef.update({ + for (final u in members.keys) + 'members.$u.role': admins.contains(u) ? 'admin' : 'member', + 'epoch': epoch + 1, + }); + case 'leave': + final leaving = members.remove(by) as Map; + await db.doc('users/$by/groupRefs/$gid').delete(); + if (members.isEmpty) { + await groupRef.delete(); + return; + } + // The fake merges map fields on update, so delete the key itself. + await groupRef.update({ + 'members.$by': FieldValue.delete(), + 'fence': [ + ...(group['fence'] as List), + {'uid': by, 'devices': leaving['devices']}, + ], + }); + case 'finalize': + await commit('membership'); + await groupRef.update({'fence': [], 'epoch': epoch + 1}); + case 'remove': + if (members[by]['role'] != 'admin') { + throw _Refused('not an administrator'); + } + await commit('membership'); + await groupRef.update({ + 'members.${op['uid']}': FieldValue.delete(), + 'invited.${op['uid']}': FieldValue.delete(), + 'epoch': epoch + 1, + }); + await db.doc('users/${op['uid']}/groupRefs/$gid').delete(); + await db.doc('users/${op['uid']}/groupInvites/$gid').delete(); + } + } + + Future close() async { + for (final s in _subs) { + await s.cancel(); + } + } +} + +class _Refused implements Exception { + _Refused(this.reason); + final String reason; +} diff --git a/test/support/group_phones.dart b/test/support/group_phones.dart new file mode 100644 index 0000000..bfa4b9f --- /dev/null +++ b/test/support/group_phones.dart @@ -0,0 +1,61 @@ +// Phones for group tests: each a real GroupService (Rust MLS core) over a +// shared fake Firestore. +import 'dart:io'; + +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:fireplace/fireplace_services.dart'; +import 'package:fireplace/src/model/groups/group_models.dart'; +import 'package:fireplace/src/model/groups/group_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class Phone { + Phone._(this.uid, this.service, this.messages, this.secrets); + final String uid; + final GroupService service; + final MemoryMessageStore messages; + final MemorySecretStore secrets; + + static Future create( + FakeFirebaseFirestore db, + String uid, + Directory root, { + DateTime Function()? clock, + }) async { + await db.collection('usernames').doc(uid).set({'uid': uid}); + await db.collection('users').doc(uid).set({ + 'username': uid, + 'displayName': uid, + }); + final secrets = MemorySecretStore(); + final device = await KeyService(db, secrets).ensureDevice(uid); + final messages = MemoryMessageStore(); + final service = GroupService( + db: db, + uid: uid, + device: device, + secrets: secrets, + historyDir: Directory('${root.path}/$uid')..createSync(recursive: true), + messages: messages, + requestTimeout: const Duration(seconds: 10), + clock: clock, + ); + await service.start(); + return Phone._(uid, service, messages, secrets); + } +} + +/// Waits (in real time) until [ready] holds. +Future until( + Future Function() ready, { + String what = 'condition', +}) async { + final end = DateTime.now().add(const Duration(seconds: 20)); + while (!await ready()) { + if (DateTime.now().isAfter(end)) fail('Timed out waiting for $what'); + await Future.delayed(const Duration(milliseconds: 50)); + } +} + +Future> texts(Phone p, String gid) async => [ + for (final m in await p.messages.watch(groupChatId(gid)).first) m.body, +]; diff --git a/test/view/chat_list/chat_list_discovery_test.dart b/test/view/chat_list/chat_list_discovery_test.dart index 55e9b61..bb90f71 100644 --- a/test/view/chat_list/chat_list_discovery_test.dart +++ b/test/view/chat_list/chat_list_discovery_test.dart @@ -92,7 +92,7 @@ void main() { await t.tap(find.byKey(const Key('newChat'))); await t.pumpAndSettle(); await t.enterText(find.byKey(const Key('peerUsername')), 'a'); - await t.tap(find.byKey(const Key('startChat'))); + await t.testTextInput.receiveAction(TextInputAction.go); await t.pump(); expect(f.chat.starts, isEmpty); expect( @@ -100,14 +100,15 @@ void main() { findsOneWidget, ); f.chat.startHold = Completer(); - await t.enterText(find.byKey(const Key('peerUsername')), 'fred'); + await t.enterText(find.byKey(const Key('peerUsername')), 'mira_k'); + await t.pump(); final submit = t .widget(find.byKey(const Key('peerUsername'))) .onSubmitted!; - submit('fred'); - submit('fred'); + submit('mira_k'); + submit('mira_k'); await t.pump(); - expect(f.chat.starts, ['fred']); + expect(f.chat.starts, ['mira_k']); expect( t.widget(find.byKey(const Key('peerUsername'))).enabled, isFalse, diff --git a/test/view/chat_list/compose_button_test.dart b/test/view/chat_list/compose_button_test.dart index e6345ef..2f06eb0 100644 --- a/test/view/chat_list/compose_button_test.dart +++ b/test/view/chat_list/compose_button_test.dart @@ -56,10 +56,11 @@ void main() { await t.tap(button); await t.pumpAndSettle(); expect(find.byKey(const Key('peerUsername')), findsOneWidget); - await t.enterText(find.byKey(const Key('peerUsername')), 'fred'); + await t.enterText(find.byKey(const Key('peerUsername')), 'mira_k'); + await t.pump(); await t.tap(find.byKey(const Key('startChat'))); await t.pumpAndSettle(); - expect(f.chat.starts, ['fred']); + expect(f.chat.starts, ['mira_k']); } finally { semantics.dispose(); } diff --git a/test/view/chat_list/new_chat_sheet_test.dart b/test/view/chat_list/new_chat_sheet_test.dart new file mode 100644 index 0000000..91fde30 --- /dev/null +++ b/test/view/chat_list/new_chat_sheet_test.dart @@ -0,0 +1,102 @@ +// The New chat panel: To: field, Create group, and the people you chat with. +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/chat/chat_screen.dart'; +import 'package:fireplace/src/view/chat_list/chat_list_screen.dart'; +import 'package:fireplace/src/view/chat_list/new_chat_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/ui_fixture.dart'; + +Future pumpList(WidgetTester t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await t.pumpWidget( + ProviderScope( + overrides: f.overrides, + child: MaterialApp( + theme: fireplaceTheme(Brightness.dark), + home: const ChatListScreen(), + ), + ), + ); + await settleUi(t); + await t.tap(find.byKey(const Key('newChat'))); + await t.pumpAndSettle(); + return f; +} + +void main() { + testWidgets('lists the people you chat with, but not incoming requests', ( + t, + ) async { + await pumpList(t); + expect(find.byType(NewChatSheet), findsOneWidget); + expect(find.text('New chat'), findsOneWidget); + expect(find.byKey(const Key('contact_fred')), findsOneWidget); + expect(find.byKey(const Key('contact_bob')), findsOneWidget); + // Theo's request waits in Message requests, not here. + expect(find.byKey(const Key('contact_theo')), findsNothing); + // No group service in this build: no Create group row. + expect(find.byKey(const Key('newGroup')), findsNothing); + }); + + testWidgets('tapping a person opens your chat with them', (t) async { + final f = await pumpList(t); + await t.tap(find.byKey(const Key('contact_bob'))); + await t.pumpAndSettle(); + expect(find.byType(ChatScreen), findsOneWidget); + expect(f.chat.starts, isEmpty); + }); + + testWidgets('typing filters, and a new username can be messaged', (t) async { + final f = await pumpList(t); + await t.enterText(find.byKey(const Key('peerUsername')), 'fr'); + await t.pump(); + expect(find.byKey(const Key('contact_fred')), findsOneWidget); + expect(find.byKey(const Key('contact_bob')), findsNothing); + // "fr" is too short to be a username. + expect(find.byKey(const Key('startChat')), findsNothing); + + await t.enterText(find.byKey(const Key('peerUsername')), 'fred'); + await t.pump(); + // An existing contact: no second "Message" row. + expect(find.byKey(const Key('startChat')), findsNothing); + + await t.enterText(find.byKey(const Key('peerUsername')), 'mira_k'); + await t.pump(); + expect(find.text('Message @mira_k'), findsOneWidget); + await t.tap(find.byKey(const Key('startChat'))); + await t.pumpAndSettle(); + expect(f.chat.starts, ['mira_k']); + }); + + testWidgets('Cancel closes the panel', (t) async { + await pumpList(t); + await t.tap(find.byKey(const Key('cancelNewChat'))); + await t.pumpAndSettle(); + expect(find.byType(NewChatSheet), findsNothing); + }); + + testWidgets('Create group shows when groups are on', (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await t.pumpWidget( + ProviderScope( + overrides: [...f.overrides, groupsOnProvider.overrideWithValue(true)], + child: MaterialApp( + theme: fireplaceTheme(Brightness.dark), + home: const ChatListScreen(), + ), + ), + ); + await settleUi(t); + await t.tap(find.byKey(const Key('newChat'))); + await t.pumpAndSettle(); + expect(find.text('Create group'), findsOneWidget); + }); +} diff --git a/test/view/groups/group_screens_test.dart b/test/view/groups/group_screens_test.dart new file mode 100644 index 0000000..edbfc63 --- /dev/null +++ b/test/view/groups/group_screens_test.dart @@ -0,0 +1,508 @@ +// Group screens over real group services (decision 0050): the signed-in +// account is Alice, as in UiFixture; Bob is a second phone. +import 'dart:async'; +import 'dart:io'; + +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:fireplace/src/app/chat_color_preference.dart'; +import 'package:fireplace/src/app/group_providers.dart'; +import 'package:fireplace/src/styles/chat_colors.dart'; +import 'package:fireplace/src/styles/theme.dart'; +import 'package:fireplace/src/view/chat_list/chat_list_screen.dart'; +import 'package:fireplace/src/view/groups/group_chat_screen.dart'; +import 'package:fireplace/src/view/groups/group_info_screen.dart'; +import 'package:fireplace/src/view/groups/new_group_screen.dart'; +import 'package:fireplace/src/view/safety/requests_screen.dart'; +import 'package:fireplace/src/widgets/avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../support/fake_group_server.dart'; +import '../../support/group_phones.dart'; +import '../../support/ui_fixture.dart'; + +void main() { + late Directory root; + late FakeFirebaseFirestore db; + late FakeGroupServer server; + late Phone alice, bob; + + Future setUpPhones(WidgetTester t) => t.runAsync(() async { + root = await Directory.systemTemp.createTemp('group_screens'); + db = FakeFirebaseFirestore(); + server = FakeGroupServer(db); + alice = await Phone.create(db, 'alice', root); + bob = await Phone.create(db, 'bob', root); + server + ..watchAccount('alice') + ..watchAccount('bob'); + }); + + /// Closes the phones while both event loops run: a close can wait on work + /// a screen started under the test's fake clock. + Future tearDownPhones(WidgetTester t) async { + await t.pumpWidget(const SizedBox()); + var closed = false; + unawaited( + Future.wait([alice.service.close(), bob.service.close(), server.close()]) + .whenComplete(() => closed = true), + ); + for (var i = 0; i < 200 && !closed; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await t.pump(const Duration(milliseconds: 25)); + } + expect(closed, isTrue); + await t.runAsync(() => root.delete(recursive: true)); + } + + /// [admin] creates "Book club" and invites [member], who joins. + Future bookClub(WidgetTester t, Phone admin, Phone member) async => + (await t.runAsync(() async { + final gid = await admin.service.create('Book club', [member.uid]); + await until( + () async => member.service.invitations.any((i) => i.gid == gid), + what: 'invitation', + ); + await member.service.accept(gid); + await until( + () async => admin.service.groups[gid]!.members.length == 2, + what: 'joined', + ); + return gid; + }))!; + + /// Lets both the real event loop (MLS, fake server) and the test's fake + /// clock run until [done] holds. + Future settle(WidgetTester t, bool Function() done) async { + for (var i = 0; i < 200 && !done(); i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await t.pump(const Duration(milliseconds: 25)); + } + expect(done(), isTrue); + } + + /// A control in the held-message menu, the same one 1:1 chats use. + Finder action(String id) => find.byKey(ValueKey('messageAction-$id')); + + Future pump(WidgetTester t, Widget home) async { + final f = UiFixture(); + await f.seed(); + await t.pumpWidget( + ProviderScope( + overrides: [ + ...f.overrides, + groupServiceProvider.overrideWithValue(alice.service), + groupContactsProvider.overrideWithValue(const ['bob', 'fred']), + ], + child: MaterialApp(theme: fireplaceTheme(Brightness.light), home: home), + ), + ); + // Let the providers' first values arrive from the real event loop. + for (var i = 0; i < 8; i++) { + await t.runAsync( + () => Future.delayed(const Duration(milliseconds: 25)), + ); + await t.pump(const Duration(milliseconds: 25)); + } + return f; + } + + testWidgets('the chat list shows a group with who wrote the last message', ( + t, + ) async { + await setUpPhones(t); + final gid = await bookClub(t, alice, bob); + await t.runAsync(() => bob.service.send(gid, 'See you at 7?')); + await pump(t, const ChatListScreen()); + await settle( + t, + () => find.text('bob: See you at 7?').evaluate().isNotEmpty, + ); + expect(find.text('Book club'), findsOneWidget); + await tearDownPhones(t); + }); + + testWidgets('New chat offers a group; Create needs a name and a person', ( + t, + ) async { + await setUpPhones(t); + await pump(t, const ChatListScreen()); + await t.tap(find.byKey(const Key('newChat'))); + await t.pumpAndSettle(); + expect(find.byKey(const Key('peerUsername')), findsOneWidget); + await t.tap(find.byKey(const Key('newGroup'))); + await t.pumpAndSettle(); + expect(find.byType(NewGroupScreen), findsOneWidget); + Widget create() => + t.widget(find.byKey(const Key('createGroup'))); + expect((create() as TextButton).onPressed, isNull); + await t.enterText(find.byKey(const Key('groupName')), 'Book club'); + await t.pump(); + expect((create() as TextButton).onPressed, isNull); + await t.tap(find.byKey(const Key('pick_bob'))); + await t.pump(); + expect((create() as TextButton).onPressed, isNotNull); + await t.tap(find.byKey(const Key('createGroup'))); + await settle(t, () => find.byType(GroupChatScreen).evaluate().isNotEmpty); + expect(alice.service.groups.values.single.name, 'Book club'); + await settle(t, () => bob.service.invitations.isNotEmpty); + await tearDownPhones(t); + }); + + testWidgets("names and avatars mark others' messages; only your own " + 'can be deleted for everyone', (t) async { + await setUpPhones(t); + final gid = await bookClub(t, alice, bob); + await t.runAsync(() async { + await bob.service.send(gid, 'from Bob'); + await until( + () async => (await texts(alice, gid)).contains('from Bob'), + what: 'delivered', + ); + await alice.service.send(gid, 'from Alice'); + }); + await pump(t, GroupChatScreen(gid: gid)); + await settle(t, () => find.text('from Alice').evaluate().isNotEmpty); + expect(find.text('bob'), findsOneWidget); // the name above Bob's run + expect(find.text('2 people'), findsOneWidget); + + // The same held-message menu as a 1:1 chat. + await t.longPress(find.text('from Bob')); + await t.pumpAndSettle(); + expect(action('deleteForEveryone'), findsNothing); + expect(action('edit'), findsNothing); + expect(action('deleteForMe'), findsOneWidget); + expect(action('react-👍'), findsOneWidget); + await t.tapAt(const Offset(10, 10)); + await t.pumpAndSettle(); + + await t.longPress(find.text('from Alice')); + await t.pumpAndSettle(); + expect(action('edit'), findsOneWidget); + await t.tap(action('deleteForEveryone')); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('confirmRemoteDelete'))); + await settle(t, () => find.text('from Alice').evaluate().isEmpty); + await t.runAsync( + () => until( + () async => + !(await texts(bob, gid)).contains('from Alice') || + (await bob.messages.watch('group-$gid').first).any( + (m) => m.body == 'from Alice' && m.remotelyDeleted, + ), + what: 'deleted for Bob', + ), + ); + await tearDownPhones(t); + }); + + testWidgets('editing your message happens in the composer, as in a 1:1 ' + 'chat', (t) async { + await setUpPhones(t); + final gid = await bookClub(t, alice, bob); + await t.runAsync(() => alice.service.send(gid, 'See you at 7')); + await pump(t, GroupChatScreen(gid: gid)); + await settle(t, () => find.text('See you at 7').evaluate().isNotEmpty); + await t.longPress(find.text('See you at 7')); + await t.pumpAndSettle(); + await t.tap(action('edit')); + await t.pumpAndSettle(); + // No dialog: the composer holds the text, under an "Edit message" bar. + expect(find.byType(Dialog), findsNothing); + expect(find.byKey(const Key('editComposerPreview')), findsOneWidget); + final composer = find.byKey(const Key('composer')); + expect(t.widget(composer).controller!.text, 'See you at 7'); + // Nothing to save until the text changes. + expect( + t.widget(find.byKey(const Key('send'))).onPressed, + isNull, + ); + await t.enterText(composer, 'See you at 8'); + await t.pump(); + await t.tap(find.byKey(const Key('send'))); + await settle( + t, + () => + find.byKey(const Key('editComposerPreview')).evaluate().isEmpty && + find.text('See you at 8').evaluate().isNotEmpty, + ); + expect(t.widget(composer).controller!.text, isEmpty); + expect(t.takeException(), isNull); + await t.runAsync( + () => until( + () async => (await texts(bob, gid)).contains('See you at 8'), + what: 'the edit reaches Bob', + ), + ); + await tearDownPhones(t); + }); + + testWidgets('an administrator sees rename and remove; a member does not', ( + t, + ) async { + await setUpPhones(t); + final mine = await bookClub(t, alice, bob); + final theirs = await bookClub(t, bob, alice); + await pump(t, GroupInfoScreen(gid: mine)); + expect(find.byKey(const Key('renameGroup')), findsOneWidget); + expect(find.byKey(const Key('remove_bob')), findsOneWidget); + expect(find.byKey(const Key('addPeople')), findsOneWidget); + expect(find.text('Administrator'), findsOneWidget); + + await pump(t, GroupInfoScreen(gid: theirs)); + expect(find.byKey(const Key('renameGroup')), findsNothing); + expect(find.byKey(const Key('remove_bob')), findsNothing); + // Any member may invite people they chat with. + expect(find.byKey(const Key('addPeople')), findsOneWidget); + await tearDownPhones(t); + }); + + testWidgets('an invitation waits in Message requests until you join', ( + t, + ) async { + await setUpPhones(t); + final gid = (await t.runAsync(() async { + final gid = await bob.service.create('Tea club', ['alice']); + await until(() async => alice.service.invitations.isNotEmpty); + return gid; + }))!; + await pump(t, const RequestsScreen()); + expect(find.text('@bob invited you to a group'), findsOneWidget); + await t.tap(find.byKey(const Key('joinGroup'))); + await settle(t, () => alice.service.groups.containsKey(gid)); + await settle( + t, + () => find.byKey(Key('groupInvite_$gid')).evaluate().isEmpty, + ); + expect(alice.service.groups[gid]!.name, 'Tea club'); + // Join goes straight into the conversation. + await settle(t, () => find.byType(GroupChatScreen).evaluate().isNotEmpty); + expect(find.text('Tea club'), findsOneWidget); + await tearDownPhones(t); + }); + + // ------------------------------------------- colours, lines, unread, mute, roles + + testWidgets("others' messages use the soft tint of their own colour; " + 'names and avatars use its text shade', (t) async { + await setUpPhones(t); + bob.service.ownColor = 'plum'; + final gid = await bookClub(t, alice, bob); + await t.runAsync(() async { + await bob.service.send(gid, 'from Bob'); + await until( + () async => + (await texts(alice, gid)).contains('from Bob') && + alice.service.colorsOf(gid)['bob'] == 'plum', + what: 'the message and the colour', + ); + await alice.service.send(gid, 'from Alice'); + }); + await pump(t, GroupChatScreen(gid: gid)); + await settle(t, () => find.text('from Alice').evaluate().isNotEmpty); + final history = (await t.runAsync( + () => alice.messages.watch('group-$gid').first, + ))!; + String idOf(String body) => history.firstWhere((m) => m.body == body).id; + Color fill(String body) => + (t + .widget( + find.byKey(ValueKey('messageBubble-${idOf(body)}')), + ) + .decoration! + as BoxDecoration) + .color!; + expect(fill('from Bob'), ChatBubbleColor.plum.incoming(Brightness.light)); + final own = ProviderScope.containerOf( + t.element(find.byType(GroupChatScreen)), + ).read(chatBubbleColorsProvider).outgoing; + expect(fill('from Alice'), own.outgoing); + final shade = ChatBubbleColor.plum.accentText(Brightness.light); + expect( + t + .widget(find.byKey(Key('groupSender_${idOf('from Bob')}'))) + .style! + .color, + shade, + ); + expect( + t + .widget( + find.byKey(Key('groupAvatar_${idOf('from Bob')}')), + ) + .color, + shade, + ); + await tearDownPhones(t); + }); + + testWidgets('the history shows who was added, and where it starts for ' + 'someone who joined later', (t) async { + await setUpPhones(t); + final mine = await bookClub(t, alice, bob); + final theirs = await bookClub(t, bob, alice); + await pump(t, GroupChatScreen(gid: mine)); + await settle(t, () => find.text('You added bob').evaluate().isNotEmpty); + // Alice made this group: its history on her phone is complete. + expect(find.byKey(const Key('historyStart')), findsNothing); + + await pump(t, GroupChatScreen(gid: theirs)); + await settle( + t, + () => find.byKey(const Key('historyStart')).evaluate().isNotEmpty, + ); + expect( + find.text('Messages sent before you joined aren’t shown.'), + findsOneWidget, + ); + await tearDownPhones(t); + }); + + testWidgets('a group row counts unread messages until the group is opened', ( + t, + ) async { + await setUpPhones(t); + final gid = await bookClub(t, alice, bob); + await pump(t, const ChatListScreen()); + final badge = find.byKey(ValueKey('unread-group-$gid')); + await settle(t, () => find.text('Book club').evaluate().isNotEmpty); + expect(badge, findsNothing); + await t.runAsync(() async { + await bob.service.send(gid, 'one'); + await bob.service.send(gid, 'two'); + }); + await settle(t, () => badge.evaluate().isNotEmpty); + await settle( + t, + () => find + .descendant(of: badge, matching: find.text('2')) + .evaluate() + .isNotEmpty, + ); + // Lines about the group are not messages and are never counted. + await t.tap(find.byKey(Key('group_$gid'))); + await settle(t, () => find.byType(GroupChatScreen).evaluate().isNotEmpty); + await settle(t, () => find.text('two').evaluate().isNotEmpty); + await t.pageBack(); + await settle(t, () => badge.evaluate().isEmpty); + await tearDownPhones(t); + }); + + testWidgets('a group can be muted on this phone, like a 1:1 chat', (t) async { + await setUpPhones(t); + final gid = await bookClub(t, alice, bob); + await pump(t, const ChatListScreen()); + await settle(t, () => find.text('Book club').evaluate().isNotEmpty); + final container = ProviderScope.containerOf( + t.element(find.byType(ChatListScreen)), + ); + final mark = find.byKey(ValueKey('muted-group-$gid')); + expect(mark, findsNothing); + await t.runAsync(() => bob.service.send(gid, 'one')); + await settle(t, () => container.read(groupsBadgeProvider) == 1); + + // Holding the row offers the same choice as a 1:1 row. + await t.longPress(find.byKey(Key('group_$gid'))); + await t.pumpAndSettle(); + await t.tap(find.text('Mute chat')); + await settle(t, () => mark.evaluate().isNotEmpty); + // Still counted on the row, but no longer on the app icon. + expect(find.byKey(ValueKey('unread-group-$gid')), findsOneWidget); + expect(container.read(groupsBadgeProvider), 0); + expect(container.read(groupMutedProvider(gid)), isTrue); + + await t.longPress(find.byKey(Key('group_$gid'))); + await t.pumpAndSettle(); + await t.tap(find.text('Unmute chat')); + await settle(t, () => mark.evaluate().isEmpty); + expect(container.read(groupsBadgeProvider), 1); + await tearDownPhones(t); + }); + + testWidgets('Group info mutes and unmutes the group', (t) async { + await setUpPhones(t); + final gid = await bookClub(t, alice, bob); + await pump(t, GroupInfoScreen(gid: gid)); + final container = ProviderScope.containerOf( + t.element(find.byType(GroupInfoScreen)), + ); + expect(find.text('Mute'), findsOneWidget); + await t.tap(find.byKey(const Key('muteGroup'))); + await settle(t, () => find.text('Unmute').evaluate().isNotEmpty); + expect(container.read(groupMutedProvider(gid)), isTrue); + await t.tap(find.byKey(const Key('muteGroup'))); + await settle(t, () => find.text('Mute').evaluate().isNotEmpty); + expect(container.read(groupMutedProvider(gid)), isFalse); + await tearDownPhones(t); + }); + + testWidgets('an administrator makes a member an administrator', (t) async { + await setUpPhones(t); + final gid = await bookClub(t, alice, bob); + await pump(t, GroupInfoScreen(gid: gid)); + expect(find.text('Administrator'), findsOneWidget); + // The row is below the fold: bring all of it on screen before tapping. + await t.scrollUntilVisible(find.byKey(const Key('member_bob')), 200); + await t.ensureVisible(find.byKey(const Key('member_bob'))); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('member_bob'))); + await t.pumpAndSettle(); + expect(find.byKey(const Key('removeAdmin')), findsNothing); + await t.tap(find.byKey(const Key('makeAdmin'))); + await settle(t, () => alice.service.groups[gid]!.isAdmin('bob')); + await settle(t, () => find.text('Administrator').evaluate().length == 2); + // Now the choice is to take it away again. + await t.ensureVisible(find.byKey(const Key('member_bob'))); + await t.pumpAndSettle(); + await t.tap(find.byKey(const Key('member_bob'))); + await t.pumpAndSettle(); + expect(find.byKey(const Key('removeAdmin')), findsOneWidget); + await t.tapAt(const Offset(10, 10)); + await t.pumpAndSettle(); + await tearDownPhones(t); + }); + + testWidgets('someone invited is listed as invited until they join', ( + t, + ) async { + await setUpPhones(t); + final gid = (await t.runAsync(() async { + final gid = await alice.service.create('Book club', ['bob']); + await until( + () async => alice.service.invitedTo(gid).containsKey('bob'), + what: 'invited', + ); + return gid; + }))!; + await pump(t, GroupInfoScreen(gid: gid)); + await settle( + t, + () => find.byKey(const Key('invitedHeader')).evaluate().isNotEmpty, + ); + expect(find.textContaining('1 person, 1 invited'), findsOneWidget); + final row = find.byKey(const Key('member_bob')); + await t.scrollUntilVisible(row, 200); + expect( + find.descendant(of: row, matching: find.text('Invited')), + findsOneWidget, + ); + // Not a member yet, so not offered as an administrator. + await t.tap(row); + await t.pumpAndSettle(); + expect(find.byKey(const Key('makeAdmin')), findsNothing); + + await t.runAsync(() => bob.service.accept(gid)); + await settle( + t, + () => find.byKey(const Key('invitedHeader')).evaluate().isEmpty, + ); + await t.scrollUntilVisible(find.textContaining('2 people'), -200); + expect(find.textContaining('2 people'), findsOneWidget); + await tearDownPhones(t); + }); +}