import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:xml/xml.dart' as xml; import '../models/nextcloud_item.dart'; extension XmlElementHelper on xml.XmlNode { Iterable findLocalChildren(String localName) { return children.whereType().where( (e) => e.name.local.toLowerCase() == localName.toLowerCase(), ); } Iterable findLocalDescendants(String localName) { if (this is xml.XmlDocument) { return (this as xml.XmlDocument).descendants.whereType().where( (e) => e.name.local.toLowerCase() == localName.toLowerCase(), ); } else if (this is xml.XmlElement) { return (this as xml.XmlElement).descendants.whereType().where( (e) => e.name.local.toLowerCase() == localName.toLowerCase(), ); } return []; } } class NextcloudService { final String serverUrl; final String username; final String password; NextcloudService({ required this.serverUrl, required this.username, required this.password, }); String get _cleanServerUrl { var url = serverUrl.trim(); if (!url.startsWith('http://') && !url.startsWith('https://')) { url = 'https://$url'; } if (url.endsWith('/')) { url = url.substring(0, url.length - 1); } return url; } Map get _headers { final credentials = base64Encode(utf8.encode('$username:$password')); return { 'Authorization': 'Basic $credentials', 'OCS-APIRequest': 'true', 'Accept': 'application/json, application/xml', 'User-Agent': 'Nextcloud-Flutter-Client/1.0', }; } Future testConnection() async { try { final davPath = '$_cleanServerUrl/remote.php/dav/files/$username/'; debugPrint('[Nextcloud Test] Connecting to WebDAV endpoint: $davPath'); final request = http.Request('PROPFIND', Uri.parse(davPath)) ..headers.addAll({ ..._headers, 'Depth': '0', 'Content-Type': 'application/xml', }) ..body = ''' '''; final streamed = await http.Client().send(request); debugPrint('[Nextcloud Test] Connection response HTTP status: ${streamed.statusCode}'); if (streamed.statusCode == 207 || streamed.statusCode == 200) { return true; } // Try fallback endpoint /remote.php/webdav/ final legacyPath = '$_cleanServerUrl/remote.php/webdav/'; debugPrint('[Nextcloud Test] Testing legacy WebDAV endpoint: $legacyPath'); final req2 = http.Request('PROPFIND', Uri.parse(legacyPath)) ..headers.addAll({ ..._headers, 'Depth': '0', 'Content-Type': 'application/xml', }); final st2 = await http.Client().send(req2); debugPrint('[Nextcloud Test] Legacy WebDAV response HTTP status: ${st2.statusCode}'); if (st2.statusCode == 207 || st2.statusCode == 200) { return true; } if (streamed.statusCode == 401 || st2.statusCode == 401) { throw Exception('Invalid username or password (401 Unauthorized)'); } else if (streamed.statusCode == 404 && st2.statusCode == 404) { throw Exception('Nextcloud WebDAV endpoint not found at $davPath'); } else { throw Exception('Server returned status code ${streamed.statusCode}'); } } catch (e) { debugPrint('[Nextcloud Test] Error: $e'); final str = e.toString(); if (str.contains('XMLHttpRequest') || str.contains('ClientException') || str.contains('Failed to fetch')) { throw Exception( 'Browser CORS Policy Blocked: The web browser blocked the connection preflight request to $_cleanServerUrl. ' 'To bypass browser CORS, run the application as a native Windows app (flutter run -d windows) ' 'or configure CORS headers on your Nextcloud server.', ); } if (e is Exception) rethrow; throw Exception('Could not connect to Nextcloud host: $e'); } } Future> fetchDirectory(String folderPath) async { var cleanPath = folderPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'; debugPrint('[Nextcloud DAV] Requesting PROPFIND for $url'); final request = http.Request('PROPFIND', Uri.parse(url)) ..headers.addAll({ ..._headers, 'Depth': '1', 'Content-Type': 'application/xml', }); const body = ''' '''; request.body = body; var streamedResponse = await http.Client().send(request); var responseBody = await streamedResponse.stream.bytesToString(); debugPrint('[Nextcloud DAV] PROPFIND primary URL status: ${streamedResponse.statusCode}, bytes: ${responseBody.length}'); if (streamedResponse.statusCode != 207 && streamedResponse.statusCode != 200) { final fallbackUrl = '$_cleanServerUrl/remote.php/webdav$cleanPath'; debugPrint('[Nextcloud DAV] Primary URL failed (${streamedResponse.statusCode}). Trying fallback: $fallbackUrl'); final req2 = http.Request('PROPFIND', Uri.parse(fallbackUrl)) ..headers.addAll({ ..._headers, 'Depth': '1', 'Content-Type': 'application/xml', }) ..body = body; final res2 = await http.Client().send(req2); final body2 = await res2.stream.bytesToString(); debugPrint('[Nextcloud DAV] Fallback URL response status: ${res2.statusCode}, bytes: ${body2.length}'); if (res2.statusCode == 207 || res2.statusCode == 200) { streamedResponse = res2; responseBody = body2; } else { throw Exception('Failed to load directory $cleanPath. HTTP ${streamedResponse.statusCode}'); } } final document = xml.XmlDocument.parse(responseBody); final responses = document.findLocalDescendants('response'); debugPrint('[Nextcloud DAV] Found ${responses.length} response nodes in XML'); List items = []; for (var res in responses) { final hrefNode = res.findLocalChildren('href').firstOrNull ?? res.findLocalDescendants('href').firstOrNull; final href = hrefNode?.innerText ?? ''; var normalizedHref = Uri.decodeFull(href).trim(); if (normalizedHref.endsWith('/') && normalizedHref.length > 1) { normalizedHref = normalizedHref.substring(0, normalizedHref.length - 1); } var targetDav = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'.trim(); if (targetDav.endsWith('/') && targetDav.length > 1) { targetDav = targetDav.substring(0, targetDav.length - 1); } var targetLegacy = '$_cleanServerUrl/remote.php/webdav$cleanPath'.trim(); if (targetLegacy.endsWith('/') && targetLegacy.length > 1) { targetLegacy = targetLegacy.substring(0, targetLegacy.length - 1); } // Skip current root directory entry itself if (normalizedHref == targetDav || normalizedHref == targetLegacy || normalizedHref.endsWith('/remote.php/dav/files/$username') || normalizedHref.endsWith('/remote.php/webdav')) { continue; } final props = res.findLocalDescendants('prop'); if (props.isEmpty) continue; bool isCollection = false; String? sizeStr; String? lastModStr; String? mimeType; bool isFav = false; String? fileId; for (var prop in props) { if (!isCollection) { final resTypeNode = prop.findLocalChildren('resourcetype').firstOrNull; if (resTypeNode != null && resTypeNode.findLocalChildren('collection').isNotEmpty) { isCollection = true; } } sizeStr ??= prop.findLocalChildren('size').firstOrNull?.innerText ?? prop.findLocalChildren('getcontentlength').firstOrNull?.innerText; lastModStr ??= prop.findLocalChildren('getlastmodified').firstOrNull?.innerText; mimeType ??= prop.findLocalChildren('getcontenttype').firstOrNull?.innerText; if (!isFav) { isFav = prop.findLocalChildren('favorite').firstOrNull?.innerText == '1'; } fileId ??= prop.findLocalChildren('fileid').firstOrNull?.innerText; } final name = Uri.decodeFull(href.split('/').where((s) => s.isNotEmpty).last); if (name.isEmpty) continue; final size = int.tryParse(sizeStr ?? '0') ?? 0; final lastMod = lastModStr != null ? DateTime.tryParse(lastModStr) ?? DateTime.now() : DateTime.now(); final itemType = NextcloudItem.deduceType(name, isCollection, mimeType); final validId = (fileId != null && fileId.isNotEmpty) ? fileId : name; items.add(NextcloudItem( id: validId, name: name, path: '$cleanPath$name', type: itemType, size: size, lastModified: lastMod, isFavorite: isFav, mimeType: mimeType, previewUrl: '$_cleanServerUrl/core/preview?fileId=$validId&x=500&y=500', )); } debugPrint('[Nextcloud DAV] Successfully parsed ${items.length} items from directory $cleanPath'); return items; } Future fetchUserQuota() async { final url = '$_cleanServerUrl/ocs/v1.php/cloud/user?format=json'; debugPrint('[Nextcloud OCS] Fetching user quota from $url'); final response = await http.get(Uri.parse(url), headers: _headers); debugPrint('[Nextcloud OCS] User quota response status: ${response.statusCode}'); if (response.statusCode == 200) { final data = jsonDecode(response.body); final ocsData = data['ocs']?['data'] ?? {}; final quota = ocsData['quota'] ?? {}; final used = (quota['used'] as num?)?.toInt() ?? 0; final totalRaw = (quota['total'] as num?)?.toInt() ?? 0; final total = totalRaw < 0 ? -1 : totalRaw; final pct = total > 0 ? (used / total).clamp(0.0, 1.0) : 0.0; final display = ocsData['displayname'] ?? username; final email = ocsData['email'] ?? '$username@$serverUrl'; return NextcloudUserQuota( usedBytes: used, totalBytes: total, usagePercentage: pct, userName: display, email: email, serverVersion: 'Nextcloud Server', ); } else { throw Exception('Failed to fetch user quota. HTTP ${response.statusCode}'); } } Future> fetchActivities() async { try { final url = '$_cleanServerUrl/ocs/v2.php/apps/activity/api/v2/activity?format=json'; debugPrint('[Nextcloud OCS] Fetching activity feed from $url'); final response = await http.get(Uri.parse(url), headers: _headers); debugPrint('[Nextcloud OCS] Activity response status: ${response.statusCode}'); if (response.statusCode == 200) { final data = jsonDecode(response.body); final rawData = data['ocs']?['data']; List list = []; if (rawData is List) { list = rawData; } else if (rawData is Map && rawData['activity'] is List) { list = rawData['activity']; } return list.map((a) { return NextcloudActivity( id: (a['activity_id'] ?? '').toString(), title: a['subject'] ?? 'Server Activity', subject: a['message'] ?? (a['subject'] ?? ''), timestamp: DateTime.fromMillisecondsSinceEpoch((a['timestamp'] as int? ?? 0) * 1000), icon: Icons.cloud_outlined, author: a['user'] ?? username, ); }).toList(); } } catch (e) { debugPrint('[Nextcloud OCS Activity] Error: $e'); } return []; } Future uploadFile(String folderPath, String fileName, Uint8List fileBytes) async { var cleanPath = folderPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$fileName'; debugPrint('[Nextcloud DAV] Uploading file to $url'); final response = await http.put( Uri.parse(url), headers: { ..._headers, 'Content-Type': 'application/octet-stream', }, body: fileBytes, ); debugPrint('[Nextcloud DAV] Upload status: ${response.statusCode}'); return response.statusCode == 201 || response.statusCode == 204 || response.statusCode == 200; } Future deleteItem(String itemPath) async { var cleanPath = itemPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'; debugPrint('[Nextcloud DAV] Deleting item at $url'); final response = await http.delete(Uri.parse(url), headers: _headers); debugPrint('[Nextcloud DAV] Delete status: ${response.statusCode}'); return response.statusCode == 204 || response.statusCode == 200; } Future createFolder(String parentPath, String folderName) async { var cleanPath = parentPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; if (!cleanPath.endsWith('/')) cleanPath = '$cleanPath/'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath$folderName'; debugPrint('[Nextcloud DAV] Creating folder at $url'); final request = http.Request('MKCOL', Uri.parse(url))..headers.addAll(_headers); final response = await http.Client().send(request); debugPrint('[Nextcloud DAV] Create folder status: ${response.statusCode}'); return response.statusCode == 201; } Future toggleFavorite(String itemPath, bool currentFavoriteState) async { var cleanPath = itemPath.trim(); if (!cleanPath.startsWith('/')) cleanPath = '/$cleanPath'; final url = '$_cleanServerUrl/remote.php/dav/files/$username$cleanPath'; final newFavVal = currentFavoriteState ? '0' : '1'; debugPrint('[Nextcloud DAV] Toggling favorite ($newFavVal) for $url'); final body = ''' $newFavVal '''; final request = http.Request('PROPPATCH', Uri.parse(url)) ..headers.addAll({ ..._headers, 'Content-Type': 'application/xml', }) ..body = body; final response = await http.Client().send(request); debugPrint('[Nextcloud DAV] Toggle favorite status: ${response.statusCode}'); return response.statusCode == 207 || response.statusCode == 200; } }