worldhopper/lib/services/koreader_sync_service.dart
Felipe M. bb579719f9
All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
fix: prevent double-save on exit, surface KOReader sync auth failures
- Add guard flag to prevent both onPopInvokedWithResult and dispose()
  from saving progress (double DB writes + double sync pushes)
- Capture ref-dependent values synchronously in _saveProgress before
  any await to prevent accessing disposed ref after super.dispose()
- Guard ref.invalidate() calls with mounted check for dispose safety
- Add KoreaderSyncAuthException so 401 errors in getProgress and
  updateProgress propagate instead of being silently swallowed
- Show a warning snackbar (once per session) when auth fails so the
  user knows sync credentials need updating

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 00:12:06 +01:00

226 lines
7.3 KiB
Dart

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<bool> 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<bool> 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<KoreaderProgress?> 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<String, dynamic>;
// 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<bool> 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<String> 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 = <int>[
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 = <int>[];
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<String> _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<String> getDeviceId() => _getDeviceId();
}