implemented an E2E-encrypted Nextcloud push-v2 notification system with support for RSA decryption and signature verification; introduced an iOS Notification Service Extension and native AppDelegate handlers for Talk actions (inline reply and mark-as-read); replaced the legacy notification registration with a new lifecycle managing app passwords and secure keypair storage; added background message handling with tray synchronization and a test notification utility in the settings.

This commit is contained in:
2026-07-04 22:50:18 +02:00
parent 32f7c311bc
commit 74a2ddd17f
56 changed files with 2987 additions and 285 deletions
@@ -0,0 +1,20 @@
import 'package:http/http.dart' as http;
import '../nextcloud_ocs.dart';
/// Revokes the current app password server-side via
/// `DELETE /ocs/v2.php/core/apppassword`. Best-effort: the shared OCS headers
/// authenticate with the app password itself (it revokes the credential it was
/// made with) and the result is ignored — logout clears local state regardless.
class DeleteAppPassword {
final http.Client _client;
DeleteAppPassword({http.Client? client}) : _client = client ?? http.Client();
Future<void> run() async {
await _client.delete(
NextcloudOcs.uri('core/apppassword'),
headers: NextcloudOcs.headers(),
);
}
}
@@ -0,0 +1,45 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../nextcloud_ocs.dart';
/// Exchanges the user's real Nextcloud password for a scoped app password via
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
/// which is what the push-v2 registration binds to.
///
/// Must authenticate with the *real* password — an app password cannot mint
/// another one.
class GetAppPassword {
final http.Client _client;
GetAppPassword({http.Client? client}) : _client = client ?? http.Client();
/// Returns the freshly minted app password. Throws on any transport or
/// protocol error — callers treat push registration as best-effort and swallow
/// failures.
Future<String> run() async {
final response = await _client.get(
NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one —
// this endpoint requires the real password.
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
},
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('getapppassword HTTP ${response.statusCode}');
}
final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null;
if (appPassword == null || appPassword.isEmpty) {
throw Exception('getapppassword: no apppassword in response');
}
return appPassword;
}
}
@@ -11,7 +11,15 @@ class CapabilitiesResponse {
@JsonKey(defaultValue: false)
final bool viewForeignTimetables;
CapabilitiesResponse({required this.viewForeignTimetables});
/// Whether the backend push-proxy feature is configured and enabled for this
/// user. The app only registers for push when this is true.
@JsonKey(defaultValue: false)
final bool pushNotifications;
CapabilitiesResponse({
required this.viewForeignTimetables,
required this.pushNotifications,
});
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
_$CapabilitiesResponseFromJson(json);
@@ -10,8 +10,12 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
Map<String, dynamic> json,
) => CapabilitiesResponse(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
pushNotifications: json['pushNotifications'] as bool? ?? false,
);
Map<String, dynamic> _$CapabilitiesResponseToJson(
CapabilitiesResponse instance,
) => <String, dynamic>{'viewForeignTimetables': instance.viewForeignTimetables};
) => <String, dynamic>{
'viewForeignTimetables': instance.viewForeignTimetables,
'pushNotifications': instance.pushNotifications,
};
@@ -0,0 +1,40 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
/// device-identifier signature, stores the routing metadata and starts
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
class PushDeviceRegister {
final Dio _dio;
PushDeviceRegister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<void> run({
required String deviceIdentifier,
required String deviceIdentifierSignature,
required String userPublicKey,
required String pushToken,
required String platform,
String? appVersion,
}) async {
try {
await _dio.put<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
data: {
'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature,
'userPublicKey': userPublicKey,
'pushToken': pushToken,
'platform': platform,
'appVersion': ?appVersion,
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
/// Triggers a test push to all of the current user's registered devices via
/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
/// backend dispatched to (0 when none are registered).
class PushDeviceTest {
final Dio _dio;
PushDeviceTest({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<int> run() async {
try {
final response = await _dio.post<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('me/push-device/test'),
);
return (response.data?['devices'] as num?)?.toInt() ?? 0;
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
/// Removes this device's push subscription from MarianumConnect via
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
/// (204 even when the row is already gone).
class PushDeviceUnregister {
final Dio _dio;
PushDeviceUnregister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<void> run({required String deviceIdentifier}) async {
try {
await _dio.delete<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
queryParameters: {'deviceIdentifier': deviceIdentifier},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -1,22 +0,0 @@
import 'dart:convert';
import 'dart:developer';
import 'package:http/http.dart' as http;
import '../../mhsl_api.dart';
import 'notify_register_params.dart';
class NotifyRegister extends MhslApi<void> {
NotifyRegisterParams params;
NotifyRegister(this.params) : super('notify/register/');
@override
void assemble(String raw) {}
@override
Future<http.Response> request(Uri uri) {
var requestString = jsonEncode(params.toJson());
log('register at push proxy with username ${params.username}');
return http.post(uri, body: requestString);
}
}
@@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'notify_register_params.g.dart';
@JsonSerializable()
class NotifyRegisterParams {
String username;
String password;
String fcmToken;
NotifyRegisterParams({
required this.username,
required this.password,
required this.fcmToken,
});
factory NotifyRegisterParams.fromJson(Map<String, dynamic> json) =>
_$NotifyRegisterParamsFromJson(json);
Map<String, dynamic> toJson() => _$NotifyRegisterParamsToJson(this);
}
@@ -1,23 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'notify_register_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
NotifyRegisterParams _$NotifyRegisterParamsFromJson(
Map<String, dynamic> json,
) => NotifyRegisterParams(
username: json['username'] as String,
password: json['password'] as String,
fcmToken: json['fcmToken'] as String,
);
Map<String, dynamic> _$NotifyRegisterParamsToJson(
NotifyRegisterParams instance,
) => <String, dynamic>{
'username': instance.username,
'password': instance.password,
'fcmToken': instance.fcmToken,
};