Files
tajniak81andClaude Opus 5.5 3356089c13 Android app (apps/mobile) with Flutter
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>
2026-10-18 22:45:06 +02:00

207 lines
7.1 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'package:matrix/matrix.dart';
import 'package:unifiedpush/unifiedpush.dart';
import 'notifications.dart';
/// Push notifications through UnifiedPush.
///
/// The user picks a distributor app (ntfy, or another UnifiedPush
/// distributor) which gives this app an endpoint URL. The endpoint becomes a
/// Matrix pusher: the homeserver posts each notification to the
/// distributor's Matrix gateway, the distributor wakes the app, and the app
/// fetches the event (decrypting it if it can) and shows it.
///
/// Pushes are `event_id_only`, so neither the gateway nor the distributor
/// sees message content, not even in unencrypted rooms.
///
/// No Google services are involved, so this works without a Firebase
/// project; the cost is that the user needs a distributor installed.
class Push {
Push._();
static const appId = 'io.github.dariuszjaniak.warp';
static const _endpointKey = 'warp.push.endpoint';
static const _storage = FlutterSecureStorage();
/// Where a push goes when the distributor's server has no Matrix gateway
/// of its own: the UnifiedPush project's public one, which forwards to any
/// endpoint.
static const fallbackGateway =
'https://matrix.gateway.unifiedpush.org/_matrix/push/v1/notify';
static Client? _client;
/// Sets up the push receiver. Called on every start, in the foreground and
/// when a push starts the app in the background.
static Future<void> init(Client client) async {
_client = client;
final registered = await UnifiedPush.initialize(
onNewEndpoint: (endpoint, _) => _onNewEndpoint(endpoint.url),
onUnregistered: (_) => _onUnregistered(),
onRegistrationFailed: (reason, _) =>
Logs().w('UnifiedPush registration failed: $reason'),
onMessage: (message, _) => _onMessage(message),
);
// The connector wants a register() on every start to keep its
// registration with the distributor alive.
if (registered && client.isLogged()) {
await UnifiedPush.register(features: const []);
}
}
/// The installed distributors, by package name.
static Future<List<String>> distributors() => UnifiedPush.getDistributors();
static Future<String?> currentDistributor() => UnifiedPush.getDistributor();
/// Turns push on with [distributor], or the default one when null. Returns
/// false when there is no distributor to use and the user has to pick one.
static Future<bool> enable({String? distributor}) async {
if (distributor != null) {
await UnifiedPush.saveDistributor(distributor);
} else if (!await UnifiedPush.tryUseCurrentOrDefaultDistributor()) {
return false;
}
await UnifiedPush.register(features: const []);
return true;
}
/// Turns push off: unregisters from the distributor and removes the
/// pusher from the homeserver.
static Future<void> disable() async {
await UnifiedPush.unregister();
await _onUnregistered();
}
/// Removes this device's pusher, when signing out. The homeserver also
/// removes it when the session ends, so this is best effort.
static Future<void> forget() async {
try {
await UnifiedPush.unregister();
} catch (_) {}
await _storage.delete(key: _endpointKey);
}
static Future<void> _onNewEndpoint(String endpoint) async {
final client = _client;
if (client == null || !client.isLogged()) return;
final previous = await _storage.read(key: _endpointKey);
if (previous != null && previous != endpoint) {
await _deletePusher(client, previous);
}
final gateway = await _gatewayFor(endpoint);
await client.postPusher(
Pusher(
appId: appId,
pushkey: endpoint,
appDisplayName: 'Warp',
deviceDisplayName: client.deviceName ?? 'Warp Android',
kind: 'http',
lang: 'en',
data: PusherData(url: Uri.parse(gateway), format: 'event_id_only'),
),
append: false,
);
await _storage.write(key: _endpointKey, value: endpoint);
Logs().i('Pusher registered with gateway $gateway');
}
static Future<void> _onUnregistered() async {
final client = _client;
final endpoint = await _storage.read(key: _endpointKey);
if (client != null && endpoint != null && client.isLogged()) {
await _deletePusher(client, endpoint);
}
await _storage.delete(key: _endpointKey);
}
static Future<void> _deletePusher(Client client, String endpoint) async {
try {
await client.deletePusher(PusherId(appId: appId, pushkey: endpoint));
} catch (e) {
Logs().w('Could not remove the old pusher', e);
}
}
/// The Matrix gateway for [endpoint]. UnifiedPush's discovery: a server
/// that has one answers `GET /_matrix/push/v1/notify` with
/// `{"unifiedpush": {"gateway": "matrix"}}`.
static Future<String> _gatewayFor(String endpoint) async {
final uri = Uri.parse(endpoint);
final candidate = uri.replace(
path: '/_matrix/push/v1/notify',
query: null,
fragment: null,
);
try {
final response = await http
.get(candidate)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final json = jsonDecode(response.body);
if (json is Map &&
json['unifiedpush'] is Map &&
json['unifiedpush']['gateway'] == 'matrix') {
return candidate.toString();
}
}
} catch (e) {
Logs().v('No Matrix gateway at ${candidate.origin}', e);
}
return fallbackGateway;
}
static Future<void> _onMessage(PushMessage message) async {
final client = _client;
Map<String, Object?> notification;
try {
final json = jsonDecode(utf8.decode(message.content));
notification = Map<String, Object?>.from(
(json is Map && json['notification'] is Map)
? json['notification'] as Map
: json as Map,
);
} catch (e) {
Logs().w('Push that is not a Matrix notification', e);
return;
}
final push = PushNotification.fromJson(notification);
final roomId = push.roomId;
// A push with no event is the homeserver saying the counts changed:
// everything was read on another device.
if (push.eventId == null || roomId == null) {
if ((push.counts?.unread ?? 0) == 0) await Notifications.clearAll();
return;
}
if (client == null) return;
try {
// Null when the event has already been seen (read on another device,
// or shown from the sync while the app was running).
final event = await client.getEventByPushNotification(push);
if (event != null) await Notifications.showEvent(event);
return;
} catch (e, s) {
Logs().w('Could not fetch the pushed event', e, s);
}
// Fetching failed (no network, or the session is gone): still say
// something arrived, without content.
final room = client.getRoomById(roomId);
await Notifications.show(
roomId: roomId,
title:
room?.getLocalizedDisplayname() ??
push.roomName ??
push.senderDisplayName ??
'Warp',
body: 'New message',
);
}
}