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
+211
View File
@@ -0,0 +1,211 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:marianum_mobile/push/push_avatar.dart';
final Uint8List _fakeBytes = Uint8List.fromList([
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 1, 2, 3, //
]);
http.Response _imageResponse() => http.Response.bytes(
_fakeBytes,
200,
headers: {'content-type': 'image/png'},
);
/// Renders a solid 40×20 PNG via dart:ui — a real decodable source image.
Future<Uint8List> _generatePng() async {
final recorder = ui.PictureRecorder();
final canvas = ui.Canvas(recorder);
canvas.drawRect(
const ui.Rect.fromLTWH(0, 0, 40, 20),
ui.Paint()..color = const ui.Color(0xFF993333),
);
final image = await recorder.endRecording().toImage(40, 20);
final data = await image.toByteData(format: ui.ImageByteFormat.png);
return data!.buffer.asUint8List();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late Directory tempDir;
setUp(() async {
tempDir = await Directory.systemTemp.createTemp('push_avatar_test');
});
tearDown(() async {
await tempDir.delete(recursive: true);
});
PushAvatarStore store({
required Future<http.Response> Function(String chatToken) fetch,
Duration timeout = const Duration(seconds: 4),
}) => PushAvatarStore(
cacheDirProvider: () async => tempDir,
fetch: fetch,
fetchTimeout: timeout,
);
group('maskAvatarCircular', () {
test('output is a square PNG sized to the shorter edge', () async {
final source = await _generatePng();
final masked = await maskAvatarCircular(source);
expect(masked, isNotNull);
// PNG magic bytes.
expect(masked!.sublist(0, 8), [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, //
]);
final codec = await ui.instantiateImageCodec(masked);
final frame = await codec.getNextFrame();
expect(frame.image.width, 20);
expect(frame.image.height, 20);
});
test('is deterministic for identical input', () async {
final source = await _generatePng();
final a = await maskAvatarCircular(source);
final b = await maskAvatarCircular(source);
expect(a, isNotNull);
expect(a, equals(b));
});
test('undecodable bytes yield null (caller falls back to raw)', () async {
expect(await maskAvatarCircular(_fakeBytes), isNull);
});
});
group('roomAvatarIcon', () {
test('fetches, caches, and serves from cache afterwards', () async {
var fetchCount = 0;
final s = store(
fetch: (_) async {
fetchCount++;
return _imageResponse();
},
);
final first = await s.roomAvatarIcon('iconToken1');
// Fake bytes are undecodable → mask falls back to the raw bytes.
expect(first.icon, _fakeBytes);
expect(first.late, isNull);
expect(fetchCount, 1);
final second = await s.roomAvatarIcon('iconToken1');
expect(second.icon, _fakeBytes);
expect(fetchCount, 1);
});
test('svg placeholder is rejected and not cached', () async {
final s = store(
fetch: (_) async => http.Response(
'<svg xmlns="http://www.w3.org/2000/svg"/>',
200,
headers: {'content-type': 'image/svg+xml'},
),
);
final lookup = await s.roomAvatarIcon('iconToken2');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
expect(File('${tempDir.path}/iconToken2').existsSync(), isFalse);
});
test('definitive fetch error yields no icon and no late future', () async {
final s = store(fetch: (_) async => throw const SocketException('down'));
final lookup = await s.roomAvatarIcon('iconToken3');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
});
test('fetch error falls back to a stale cache entry', () async {
final file = File('${tempDir.path}/iconToken4');
await file.writeAsBytes(_fakeBytes);
await file.setLastModified(
DateTime.now().subtract(const Duration(days: 20)),
);
final s = store(fetch: (_) async => throw const SocketException('down'));
final lookup = await s.roomAvatarIcon('iconToken4');
expect(lookup.icon, _fakeBytes);
});
test(
'timeout returns a late future that delivers exactly one result',
() async {
final completer = Completer<http.Response>();
var fetchCount = 0;
final s = store(
fetch: (_) {
fetchCount++;
return completer.future;
},
timeout: const Duration(milliseconds: 50),
);
final lookup = await s.roomAvatarIcon('iconToken5');
expect(lookup.icon, isNull);
expect(lookup.late, isNotNull);
completer.complete(_imageResponse());
final lateBytes = await lookup.late;
expect(lateBytes, _fakeBytes);
// The processed cache now serves directly — no second fetch.
final second = await s.roomAvatarIcon('iconToken5');
expect(second.icon, _fakeBytes);
expect(second.late, isNull);
expect(fetchCount, 1);
},
);
test('non-200 responses yield null', () async {
final s = store(fetch: (_) async => http.Response('gone', 404));
final lookup = await s.roomAvatarIcon('iconToken6');
expect(lookup.icon, isNull);
expect(lookup.late, isNull);
});
});
group('fileNameForToken / isFresh / looksLikeSvg', () {
test('url-safe tokens are used verbatim', () {
expect(PushAvatarStore.fileNameForToken('abc123_XY-z'), 'abc123_XY-z');
});
test('unsafe tokens are encoded deterministically and stay distinct', () {
final a = PushAvatarStore.fileNameForToken('a/b');
expect(a, PushAvatarStore.fileNameForToken('a/b'));
expect(a, isNot(PushAvatarStore.fileNameForToken('a.b')));
expect(RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(a), isTrue);
});
test('isFresh boundary', () {
final now = DateTime(2026, 7, 5, 12);
expect(
PushAvatarStore.isFresh(now.subtract(const Duration(days: 13)), now),
isTrue,
);
expect(
PushAvatarStore.isFresh(
now.subtract(const Duration(days: 14, hours: 1)),
now,
),
isFalse,
);
});
test('svg detection', () {
expect(
PushAvatarStore.looksLikeSvg(
Uint8List.fromList(' <svg xmlns="..."/>'.codeUnits),
),
isTrue,
);
expect(PushAvatarStore.looksLikeSvg(_fakeBytes), isFalse);
});
});
}