All checks were successful
ci/woodpecker/pr/pr Pipeline was successful
- 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>
226 lines
7.3 KiB
Dart
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();
|
|
}
|