implemented avatar management for user profiles and chat rooms, including 1:1 cropping, integrated OCS and Spreed avatar APIs, added cache invalidation logic, and updated the account settings view to display user info and profile pictures.

This commit is contained in:
2026-05-31 18:42:30 +02:00
parent f966cf302b
commit 5ebf5bccdb
9 changed files with 777 additions and 38 deletions
@@ -0,0 +1,131 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
/// Mix of two Nextcloud surfaces:
/// - User info comes from the OCS provisioning API
/// (`/ocs/v2.php/cloud/users/{userId}`).
/// - The own-avatar upload/delete uses the *core* AvatarController at
/// `/avatar/` — the OCS provisioning route has no POST (it answers 405).
/// This is the same controller the read path (`/avatar/{id}/{size}` in
/// [UserAvatar]) already talks to. CSRF is bypassed because we use Basic
/// Auth without a session cookie.
/// Core AvatarController endpoint for the logged-in user (POST sets, DELETE
/// removes). Built against the bare Nextcloud base (domain + optional path),
/// not the OCS wrapper.
Uri _coreAvatarUri() {
final endpoint = EndpointData().nextcloud();
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
}
Uri _userInfoUri() =>
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
Future<http.Response> _send(
Future<http.Response> Function(Uri uri, Map<String, String> headers)
perform,
Uri uri,
) async {
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await perform(uri, headers);
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
}
final status = response.statusCode;
if (status >= 200 && status < 300) return response;
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Cloud $uri -> HTTP $status'
: 'Cloud $uri -> HTTP $status body=$preview';
log(detail);
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
}
class SetUserAvatar {
final Uint8List bytes;
final String filename;
SetUserAvatar(this.bytes, {this.filename = 'avatar.jpg'});
Future<void> run() async {
await _send((uri, headers) async {
// Core AvatarController reads $_FILES['files']['error'][0] — the field
// must be `files[]` so PHP exposes it as an array, matching the web UI.
final req = http.MultipartRequest('POST', uri)
..headers.addAll(headers)
..files.add(
http.MultipartFile.fromBytes('files[]', bytes, filename: filename),
);
final streamed = await req.send();
return http.Response.fromStream(streamed);
}, _coreAvatarUri());
}
}
class DeleteUserAvatar {
Future<void> run() async {
await _send(
(uri, headers) => http.delete(uri, headers: headers),
_coreAvatarUri(),
);
}
}
class CloudUserInfo {
final String userId;
final String displayName;
const CloudUserInfo({required this.userId, required this.displayName});
}
/// Reads the current user's provisioning record. The OCS wrapper looks like:
/// `{ "ocs": { "meta": {...}, "data": { "id": "...", "displayname": "...", ... } } }`.
/// We only need displayname; everything else is discarded.
class GetUserInfo {
Future<CloudUserInfo> run() async {
final uri = _userInfoUri();
final response = await _send(
(u, headers) => http.get(u, headers: headers),
uri,
);
try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data =
(root['ocs'] as Map<String, dynamic>)['data']
as Map<String, dynamic>;
return CloudUserInfo(
userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '',
);
} catch (e) {
throw ParseException(
technicalDetails: 'Cloud $uri user info parse: $e',
);
}
}
}