apps/mobile is a Matrix client for Android on the famedly Matrix Dart SDK, with vodozemac (compiled from Rust during the build) for end-to-end encryption. It speaks the plain Client-Server API and uses the web client's design tokens, fonts and symbol. - Sign in by server name (.well-known), homeserver address or full user ID; sign up with the dummy and registration token stages. Sessions survive restarts: SQLite in private storage, credentials also in the Keystore, both excluded from backups and device transfers. - Room list with unread and mention counts, invites, search and filters. Timeline with grouping, day separators, notices, history on scroll, links, images and files (authenticated media). Sending text, photos, camera shots and files, with Sending/Sent/Read/Not sent and Retry. Typing, receipts, deleting. New rooms and DMs, encrypted by default; joining by address; room info with members, invite, rename, topic, notification level, leave. - Encryption: one-step setup behind a recovery key, verifying a new session with the recovery key (restoring the backup) or another session, emoji verification of people and sessions, reset. Undecryptable messages say why. Session list with verify and sign out. - Notifications while running, and push through UnifiedPush (no Firebase): the distributor's endpoint becomes an event_id_only pusher, the gateway is discovered, and a push starts the app headless to fetch, decrypt and show the event. Make targets: mobile (release APKs per ABI into bin/), mobile-run, mobile-test. Kotlin incremental compilation is off (Windows, project and pub cache on different drives), and Tink's JVM artifact is excluded in favour of tink-android. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
3.8 KiB
Dart
134 lines
3.8 KiB
Dart
import 'package:matrix/matrix.dart';
|
|
|
|
/// How an event shows in the timeline.
|
|
enum EntryKind {
|
|
/// A bubble: a message, sticker, or an encrypted event.
|
|
message,
|
|
|
|
/// A one-line notice in the middle: a join, a rename, encryption turned
|
|
/// on.
|
|
notice,
|
|
|
|
/// Not shown: edits, reactions, redactions, call signalling, state
|
|
/// nobody needs to read.
|
|
hidden,
|
|
}
|
|
|
|
/// The state events shown as notices. Everything else in a room's state
|
|
/// (power levels, join rules, ACLs, widgets) is hidden: it is what the room
|
|
/// info screen is for.
|
|
const noticeStateTypes = {
|
|
EventTypes.RoomMember,
|
|
EventTypes.RoomName,
|
|
EventTypes.RoomTopic,
|
|
EventTypes.RoomAvatar,
|
|
EventTypes.RoomCreate,
|
|
EventTypes.Encryption,
|
|
EventTypes.RoomTombstone,
|
|
};
|
|
|
|
EntryKind kindOf(Event event) {
|
|
if (event.relationshipType == RelationshipTypes.edit) return EntryKind.hidden;
|
|
switch (event.type) {
|
|
case EventTypes.Message:
|
|
case EventTypes.Sticker:
|
|
case EventTypes.Encrypted:
|
|
return EntryKind.message;
|
|
}
|
|
if (noticeStateTypes.contains(event.type)) {
|
|
if (event.type == EventTypes.RoomMember && _isProfileOnlyChange(event)) {
|
|
// A display name or avatar change of somebody already in the room is
|
|
// noise in a busy room; the bubble shows the new name anyway.
|
|
return EntryKind.hidden;
|
|
}
|
|
return EntryKind.notice;
|
|
}
|
|
return EntryKind.hidden;
|
|
}
|
|
|
|
bool _isProfileOnlyChange(Event event) {
|
|
final prev = event.prevContent;
|
|
return prev != null &&
|
|
prev['membership'] == 'join' &&
|
|
event.content['membership'] == 'join';
|
|
}
|
|
|
|
/// One row of the timeline, as the screen draws it.
|
|
sealed class TimelineItem {
|
|
const TimelineItem();
|
|
}
|
|
|
|
class DayItem extends TimelineItem {
|
|
const DayItem(this.day);
|
|
final DateTime day;
|
|
}
|
|
|
|
class EventItem extends TimelineItem {
|
|
const EventItem(
|
|
this.index, {
|
|
required this.kind,
|
|
required this.firstInGroup,
|
|
required this.lastInGroup,
|
|
});
|
|
|
|
/// The position of the event in the list given to [projectTimeline].
|
|
final int index;
|
|
final EntryKind kind;
|
|
|
|
/// Starts a run of messages from one sender: shows the name.
|
|
final bool firstInGroup;
|
|
|
|
/// Ends a run: shows the avatar and the time.
|
|
final bool lastInGroup;
|
|
}
|
|
|
|
/// What [projectTimeline] needs to know about an event.
|
|
typedef Entry = ({String sender, DateTime time, EntryKind kind});
|
|
|
|
/// Messages from one sender this close together are drawn as one group.
|
|
const groupGap = Duration(minutes: 5);
|
|
|
|
/// Turns events, newest first as the SDK keeps them, into rows, newest first
|
|
/// for a reversed list: hidden events dropped, a [DayItem] above the first
|
|
/// event of each day, and runs of messages from one sender grouped.
|
|
List<TimelineItem> projectTimeline(List<Entry> entries) {
|
|
final shown = <int>[
|
|
for (var i = 0; i < entries.length; i++)
|
|
if (entries[i].kind != EntryKind.hidden) i,
|
|
];
|
|
|
|
bool sameGroup(int a, int b) {
|
|
final x = entries[a], y = entries[b];
|
|
return x.kind == EntryKind.message &&
|
|
y.kind == EntryKind.message &&
|
|
x.sender == y.sender &&
|
|
_sameDay(x.time, y.time) &&
|
|
x.time.difference(y.time).abs() <= groupGap;
|
|
}
|
|
|
|
final items = <TimelineItem>[];
|
|
for (var n = 0; n < shown.length; n++) {
|
|
final i = shown[n];
|
|
final newer = n > 0 ? shown[n - 1] : null;
|
|
final older = n + 1 < shown.length ? shown[n + 1] : null;
|
|
items.add(
|
|
EventItem(
|
|
i,
|
|
kind: entries[i].kind,
|
|
firstInGroup: older == null || !sameGroup(i, older),
|
|
lastInGroup: newer == null || !sameGroup(i, newer),
|
|
),
|
|
);
|
|
final time = entries[i].time.toLocal();
|
|
if (older == null || !_sameDay(entries[older].time, entries[i].time)) {
|
|
items.add(DayItem(DateTime(time.year, time.month, time.day)));
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
|
|
bool _sameDay(DateTime a, DateTime b) {
|
|
final x = a.toLocal(), y = b.toLocal();
|
|
return x.year == y.year && x.month == y.month && x.day == y.day;
|
|
}
|