import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; import 'package:worldhopper/models/koreader_progress.dart'; import 'package:worldhopper/models/opds_server.dart'; /// Exception thrown when KOReader sync authentication fails (401). class KoreaderSyncAuthException implements Exception { final String message; KoreaderSyncAuthException([this.message = 'Authentication failed']); @override String toString() => 'KoreaderSyncAuthException: $message'; } /// Service for interacting with a KOReader-compatible sync server. /// /// The KOReader sync protocol uses: /// - Custom headers `x-auth-user` and `x-auth-key` for authentication /// - MD5 hashes as document identifiers /// - API versioning via Accept header (`application/vnd.koreader.v1+json`) class KoreaderSyncService { final OPDSServer _server; final Dio _dio; static const String _deviceIdKey = 'koreader_sync_device_id'; static const String _deviceName = 'Worldhopper'; KoreaderSyncService(this._server) : _dio = _createClient(_server); static Dio _createClient(OPDSServer server) { final baseUrl = server.koreaderSyncUrl ?? ''; final dio = Dio( BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 15), receiveTimeout: const Duration(seconds: 15), headers: { 'Accept': 'application/vnd.koreader.v1+json', 'Content-Type': 'application/json', }, ), ); // Add KOReader auth headers final username = server.koreaderSyncUsername; final password = server.koreaderSyncPassword; if (username != null && username.isNotEmpty && password != null && password.isNotEmpty) { // KOReader clients send the MD5 of the password as x-auth-key final hashedPassword = md5.convert(utf8.encode(password)).toString(); dio.interceptors.add( InterceptorsWrapper( onRequest: (options, handler) { options.headers['x-auth-user'] = username; options.headers['x-auth-key'] = hashedPassword; return handler.next(options); }, ), ); } return dio; } /// Test authentication against the sync server. /// Returns `true` if credentials are valid. /// Also serves as a connectivity check — a connection error means /// the server is unreachable, while a 401 means bad credentials. Future authenticate() async { try { final response = await _dio.get('/users/auth'); return response.statusCode == 200; } on DioException catch (e) { if (e.response?.statusCode == 401) { return false; } rethrow; } } /// Register a new user on the sync server. /// Returns `true` if registration succeeded. /// Throws if the server has registration disabled or user already exists. Future registerUser() async { try { final response = await _dio.post( '/users/create', data: { 'username': _server.koreaderSyncUsername, 'password': md5 .convert(utf8.encode(_server.koreaderSyncPassword ?? '')) .toString(), }, ); return response.statusCode == 201; } on DioException catch (e) { debugPrint('KOReader sync registration failed: ${e.message}'); rethrow; } } /// Get reading progress for a document. /// [documentHash] is the MD5 hash identifying the document. /// Returns `null` if no progress is stored for this document. Future getProgress(String documentHash) async { try { final response = await _dio.get('/syncs/progress/$documentHash'); if (response.statusCode == 200 && response.data is Map) { final data = response.data as Map; // Empty response means no progress stored if (data.isEmpty) return null; // Response without document field means no real progress if (!data.containsKey('document')) return null; return KoreaderProgress.fromJson(data); } return null; } on DioException catch (e) { if (e.response?.statusCode == 401) { throw KoreaderSyncAuthException(); } debugPrint('KOReader sync getProgress error: ${e.message}'); return null; } } /// Update reading progress for a document. /// Returns `true` if the update was successful. Future updateProgress({ required String documentHash, required double percentage, required String progress, String? device, String? deviceId, }) async { try { final resolvedDeviceId = deviceId ?? await _getDeviceId(); final response = await _dio.put( '/syncs/progress', data: { 'document': documentHash, 'percentage': percentage, 'progress': progress, 'device': device ?? _deviceName, 'device_id': resolvedDeviceId, }, ); return response.statusCode == 200; } on DioException catch (e) { if (e.response?.statusCode == 401) { throw KoreaderSyncAuthException(); } debugPrint('KOReader sync updateProgress error: ${e.message}'); return false; } } /// Compute the partial MD5 hash of a file, matching KOReader's algorithm. /// /// KOReader uses a non-uniform sampling strategy: it reads 1024-byte chunks /// at exponentially increasing offsets (0, 1024, 4096, 16384, …). This /// ensures the hash is dominated by the file head and is resilient to data /// appended at the tail (e.g. PDF highlight annotations). /// /// See `util.partialMD5` in KOReader source. static Future computeFileHash(File file) async { const int step = 1024; const int size = 1024; // Offsets sampled by KOReader's partialMD5: // i = -1 → lshift(1024, -2) overflows 32-bit → 0 // i = 0 → 1024 // i = 1 → 4096 // ... // i = 10 → 1073741824 final offsets = [ 0, // i = -1 (LuaJIT 32-bit overflow gives 0) for (int i = 0; i <= 10; i++) step << (2 * i), ]; final raf = await file.open(mode: FileMode.read); try { final samples = []; for (final offset in offsets) { await raf.setPosition(offset); final chunk = await raf.read(size); if (chunk.isEmpty) break; samples.addAll(chunk); } return md5.convert(samples).toString(); } finally { await raf.close(); } } /// Compute MD5 hash from a string (e.g., entry ID) for document identification. /// Used as a fallback when the actual file is not available. static String computeStringHash(String input) { return md5.convert(utf8.encode(input)).toString(); } /// Get or create a persistent device ID for this app installation. static Future _getDeviceId() async { final prefs = await SharedPreferences.getInstance(); var deviceId = prefs.getString(_deviceIdKey); if (deviceId == null) { deviceId = const Uuid().v4(); await prefs.setString(_deviceIdKey, deviceId); } return deviceId; } /// Get the device ID (public accessor for use in providers). static Future getDeviceId() => _getDeviceId(); }