implemented dual Nextcloud push registration with separate general and talk apptypes to ensure reliable Talk notification delivery; introduced stacked MessagingStyle notifications for chat threads with support for circular conversation avatars and disk caching

This commit is contained in:
2026-07-05 22:48:04 +02:00
parent 35e144799e
commit 483fea62ba
31 changed files with 2807 additions and 320 deletions
+185 -48
View File
@@ -15,11 +15,26 @@ import '../model/endpoint_data.dart';
import 'nextcloud_push_api.dart';
import 'push_keypair.dart';
import 'push_registration_store.dart';
import 'push_registration_type.dart';
/// Orchestrates the full push-v2 registration lifecycle:
/// Nextcloud device registration → MarianumConnect proxy registration, plus
/// unregister and token-refresh handling.
///
/// Every device maintains TWO Nextcloud registrations (see
/// [PushRegistrationType]) sharing one keypair: a `general` one and a
/// Talk-classified one, because NC routes Talk pushes only to apptype=talk
/// subscriptions once the user has any (e.g. the official Talk app).
class PushRegistration {
/// User agents matching nextcloud/server `IRequest` Talk patterns
/// (`USER_AGENT_TALK_ANDROID = '/^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk
/// v([^ ]*).*$/'`, `USER_AGENT_TALK_IOS = '/^Mozilla\/5\.0 \(iOS\)
/// Nextcloud\-Talk v([^ ]*).*$/'`). Sent only on the talk registration so
/// NC stores it with apptype `talk`.
static const String talkUserAgentAndroid =
'Mozilla/5.0 (Android) Nextcloud-Talk v1.0.0 MarianumMobile';
static const String talkUserAgentIos =
'Mozilla/5.0 (iOS) Nextcloud-Talk v1.0.0 MarianumMobile';
final PushKeypair _keypair;
final PushRegistrationStore _store;
final NextcloudPushApi _nextcloud;
@@ -34,13 +49,18 @@ class PushRegistration {
String get _platform => Platform.isIOS ? 'ios' : 'android';
String get _talkUserAgent =>
Platform.isIOS ? talkUserAgentIos : talkUserAgentAndroid;
/// Derives the push-proxy base URL from the active MarianumConnect endpoint,
/// so a beta/dev build registers against the matching proxy automatically.
String get _proxyServer => '${MarianumConnectEndpoint.current()}/push-proxy/';
/// Public so the push status view can compare it against the stored binding.
String get currentProxyServer =>
'${MarianumConnectEndpoint.current()}/push-proxy/';
/// Nextcloud origin the registration targets (full origin, no trailing
/// slash) — persisted alongside the registration to detect endpoint changes.
String get _ncBaseUrl => 'https://${EndpointData().nextcloud().full()}';
String get currentNcBaseUrl => 'https://${EndpointData().nextcloud().full()}';
/// Ensures the Nextcloud app password exists (idempotent, best-effort). Push
/// registration binds to it, so it must be obtained before registering.
@@ -54,28 +74,94 @@ class PushRegistration {
}
}
/// Registers this device end-to-end. No-op-safe: transport failures are
/// logged and swallowed so callers can fire-and-forget.
Future<void> register() async {
/// Ensures the second app password backing the Talk registration exists
/// (each `getapppassword` call with the real password mints a fresh one).
Future<void> ensureTalkAppPassword() async {
if (AccountData().hasAppPasswordTalk()) return;
try {
final fcmToken = await FirebaseMessaging.instance.getToken();
if (fcmToken == null || fcmToken.isEmpty) {
log('Push: no FCM token, skipping registration');
return;
}
await ensureAppPassword();
await _persistNativeAuthContext();
final appPassword = await GetAppPassword().run();
await AccountData().setAppPasswordTalk(appPassword);
} on Object catch (e) {
log('Push: could not obtain talk app password (non-blocking): $e');
}
}
final proxyServer = _proxyServer;
final ncBaseUrl = _ncBaseUrl;
final pems = await _keypair.ensure();
/// Registers this device end-to-end: both Nextcloud registrations (general,
/// then talk) each followed by their MarianumConnect proxy registration.
/// Partial results are persisted per type — one failing registration never
/// blocks the other. Returns true only when BOTH succeeded. No-op-safe:
/// transport failures are logged and swallowed so callers can
/// fire-and-forget (and simply ignore the result).
Future<bool> register() async {
final String? fcmToken;
try {
fcmToken = await FirebaseMessaging.instance.getToken();
} on Object catch (e) {
log('Push: could not obtain FCM token: $e');
await _recordAttempts('Kein FCM-Token verfügbar');
return false;
}
if (fcmToken == null || fcmToken.isEmpty) {
log('Push: no FCM token, skipping registration');
await _recordAttempts('Kein FCM-Token verfügbar');
return false;
}
await ensureAppPassword();
await ensureTalkAppPassword();
await _persistNativeAuthContext();
final PushKeypairPems pems;
try {
pems = await _keypair.ensure();
} on Object catch (e) {
log('Push: keypair unavailable: $e');
await _recordAttempts(_shortError(e));
return false;
}
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
var allOk = true;
for (final type in PushRegistrationType.values) {
final ok = await _registerType(
type: type,
fcmToken: fcmToken,
pems: pems,
appVersion: appVersion,
);
allOk = allOk && ok;
}
return allOk;
}
Future<bool> _registerType({
required PushRegistrationType type,
required String fcmToken,
required PushKeypairPems pems,
required String? appVersion,
}) async {
try {
final proxyServer = currentProxyServer;
final ncBaseUrl = currentNcBaseUrl;
final isTalk = type == PushRegistrationType.talk;
final registration = await _nextcloud.register(
pushTokenHash: generatePushTokenHash(fcmToken),
pushTokenHash: generatePushTokenHash(pushTokenVariant(fcmToken, type)),
devicePublicKeyPem: pems.publicKeyPem,
proxyServer: proxyServer,
authorizationHeader: isTalk
? AccountData().getTalkBasicAuthHeader()
: null,
userAgent: isTalk ? _talkUserAgent : null,
);
await _store.save(
type: type,
deviceIdentifier: registration.deviceIdentifier,
serverPublicKeyPem: registration.publicKey,
fcmToken: fcmToken,
@@ -83,27 +169,53 @@ class PushRegistration {
ncBaseUrl: ncBaseUrl,
);
String? appVersion;
try {
appVersion = (await PackageInfo.fromPlatform()).version;
} on Object {
appVersion = null;
}
await PushDeviceRegister().run(
deviceIdentifier: registration.deviceIdentifier,
deviceIdentifierSignature: registration.signature,
userPublicKey: registration.publicKey,
pushToken: fcmToken,
platform: _platform,
registrationType: type.wireName,
appVersion: appVersion,
);
log('Push: registered (created=${registration.created})');
log(
'Push: registered ${type.wireName} '
'(created=${registration.created})',
);
await _recordAttempt(type, null);
return true;
} on Object catch (e) {
log('Push: registration failed: $e');
log('Push: ${type.wireName} registration failed: $e');
await _recordAttempt(type, _shortError(e));
return false;
}
}
/// Persists the attempt outcome for the push status view. Storage failures
/// must never mask the actual registration result.
Future<void> _recordAttempt(PushRegistrationType type, String? error) async {
try {
await _store.saveLastRegistrationAttempt(
type: type,
at: DateTime.now(),
error: error,
);
} on Object {
// ignore — the status view simply shows the previous attempt
}
}
Future<void> _recordAttempts(String? error) async {
for (final type in PushRegistrationType.values) {
await _recordAttempt(type, error);
}
}
static String _shortError(Object e) {
final text = e.toString();
return text.length > 300 ? '${text.substring(0, 300)}' : text;
}
/// Writes the username and Nextcloud base URL into the shared keychain so the
/// native iOS Talk action handler can authenticate OCS calls without the
/// Flutter engine. Best-effort — a failure here must not abort registration.
@@ -119,19 +231,28 @@ class PushRegistration {
}
}
/// Removes the subscription from Nextcloud and the proxy. Best-effort.
/// Removes both subscriptions from Nextcloud and the proxy. Best-effort
/// each step is independent so one failure never blocks the rest.
Future<void> unregister() async {
final deviceIdentifier = await _store.deviceIdentifier();
try {
await _nextcloud.unregister();
} on Object catch (e) {
log('Push: NC unregister failed: $e');
}
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
for (final type in PushRegistrationType.values) {
final deviceIdentifier = await _store.deviceIdentifier(type);
try {
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
// The DELETE removes the subscription bound to the authenticating
// session token — each registration with its own app password.
await _nextcloud.unregister(
authorizationHeader: type == PushRegistrationType.talk
? AccountData().getTalkBasicAuthHeader()
: null,
);
} on Object catch (e) {
log('Push: proxy unregister failed: $e');
log('Push: NC unregister (${type.wireName}) failed: $e');
}
if (deviceIdentifier != null && deviceIdentifier.isNotEmpty) {
try {
await PushDeviceUnregister().run(deviceIdentifier: deviceIdentifier);
} on Object catch (e) {
log('Push: proxy unregister (${type.wireName}) failed: $e');
}
}
}
await _store.clear();
@@ -146,19 +267,24 @@ class PushRegistration {
required String current,
}) => registered != null && registered.isNotEmpty && registered != current;
/// True when an existing registration was made against a different
/// True when any existing registration was made against a different
/// MarianumConnect proxy or Nextcloud base URL than the ones currently
/// configured (dev-tools endpoint switch, live/beta/custom).
Future<bool> needsEndpointReRegistration() async {
if (!await _store.isRegistered()) return false;
return endpointChanged(
registered: await _store.registeredProxyServer(),
current: _proxyServer,
) ||
endpointChanged(
registered: await _store.registeredNcBaseUrl(),
current: _ncBaseUrl,
);
for (final type in PushRegistrationType.values) {
if (!await _store.isRegistered(type)) continue;
final changed =
endpointChanged(
registered: await _store.registeredProxyServer(type),
current: currentProxyServer,
) ||
endpointChanged(
registered: await _store.registeredNcBaseUrl(type),
current: currentNcBaseUrl,
);
if (changed) return true;
}
return false;
}
/// Re-registers when the active endpoints diverge from the registered ones.
@@ -236,9 +362,10 @@ class PushRegistration {
/// first, then the proxy) with the new token.
Future<void> onTokenRefresh() => register();
/// Full teardown for logout: unregister push, revoke the app password, then
/// clear it locally. Ordered so the proxy stops pushing before credentials
/// are gone.
/// Full teardown for logout: unregister push, revoke BOTH app passwords
/// (each authenticated with itself — the endpoint revokes the credential it
/// is called with), then clear them locally. Ordered so the proxy stops
/// pushing before credentials are gone.
Future<void> logoutCleanup() async {
await unregister();
try {
@@ -246,6 +373,16 @@ class PushRegistration {
} on Object catch (e) {
log('Push: delete app password failed: $e');
}
try {
if (AccountData().hasAppPasswordTalk()) {
await DeleteAppPassword().run(
authorizationHeader: AccountData().getTalkBasicAuthHeader(),
);
}
} on Object catch (e) {
log('Push: delete talk app password failed: $e');
}
await AccountData().clearAppPassword();
await AccountData().clearAppPasswordTalk();
}
}