diff --git a/lib/config/constants.dart b/lib/config/constants.dart index 90e6494..576d901 100644 --- a/lib/config/constants.dart +++ b/lib/config/constants.dart @@ -7,7 +7,7 @@ class AppConstants { // Database static const String databaseName = 'worldhopper.db'; - static const int databaseVersion = 8; + static const int databaseVersion = 10; // Cache settings static const int imageCacheMaxAgeDays = 7; diff --git a/lib/database/database.dart b/lib/database/database.dart index c8621c7..ccc0b65 100644 --- a/lib/database/database.dart +++ b/lib/database/database.dart @@ -22,6 +22,18 @@ final Map Function(Database)> _migrations = { await db.execute(SeriesToPageModeTable.createTable); await db.execute(SeriesCoverPageTable.createTable); }, + 9: (db) async { + await db.execute( + 'ALTER TABLE servers ADD COLUMN koreader_sync_enabled INTEGER NOT NULL DEFAULT 0'); + await db.execute('ALTER TABLE servers ADD COLUMN koreader_sync_url TEXT'); + await db + .execute('ALTER TABLE servers ADD COLUMN koreader_sync_username TEXT'); + await db + .execute('ALTER TABLE servers ADD COLUMN koreader_sync_password TEXT'); + }, + 10: (db) async { + await db.execute('ALTER TABLE reading_progress ADD COLUMN sync_hash TEXT'); + }, }; /// Database helper for SQLite operations diff --git a/lib/database/tables/reading_progress_table.dart b/lib/database/tables/reading_progress_table.dart index c694499..3941cd5 100644 --- a/lib/database/tables/reading_progress_table.dart +++ b/lib/database/tables/reading_progress_table.dart @@ -16,6 +16,7 @@ class ReadingProgressTable { last_read_at INTEGER NOT NULL, publication_cache_id TEXT, series_feed_url TEXT, + sync_hash TEXT, FOREIGN KEY (server_id) REFERENCES servers(id) ON DELETE CASCADE, FOREIGN KEY (publication_cache_id) REFERENCES publications(id) ON DELETE SET NULL, UNIQUE(publication_id, server_id) diff --git a/lib/database/tables/servers_table.dart b/lib/database/tables/servers_table.dart index 21add84..7ba172e 100644 --- a/lib/database/tables/servers_table.dart +++ b/lib/database/tables/servers_table.dart @@ -13,7 +13,11 @@ class ServersTable { username TEXT, password TEXT, created_at INTEGER NOT NULL, - last_synced_at INTEGER + last_synced_at INTEGER, + koreader_sync_enabled INTEGER NOT NULL DEFAULT 0, + koreader_sync_url TEXT, + koreader_sync_username TEXT, + koreader_sync_password TEXT ) '''; diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1eb42b9..d6736e7 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -172,6 +172,33 @@ } }, + "koreaderSyncToggle": "KOReader API Sync", + "koreaderSyncToggleSubtitle": "Sync EPUB reading progress with a KOReader-compatible server", + "koreaderSyncUrl": "Sync Server URL", + "koreaderSyncUrlHint": "https://sync.example.com", + "koreaderSyncUsername": "Sync Username", + "koreaderSyncPassword": "Sync Password", + "koreaderSyncTestConnection": "Test Connection", + "koreaderSyncTestSuccess": "Connection successful! Authenticated as {username}.", + "@koreaderSyncTestSuccess": { + "placeholders": { + "username": { "type": "String" } + } + }, + "koreaderSyncTestFailed": "Connection failed: {error}", + "@koreaderSyncTestFailed": { + "placeholders": { + "error": { "type": "String" } + } + }, + "koreaderSyncValidationUrlRequired": "Sync server URL is required when KOReader sync is enabled", + "koreaderSyncValidationUrlInvalid": "Please enter a valid URL", + "koreaderSyncValidationUsernameRequired": "Sync username is required", + "koreaderSyncValidationPasswordRequired": "Sync password is required", + "koreaderSyncAuthFailed": "KOReader sync: authentication failed. Check your server credentials.", + "koreaderSyncHelp": "Connect to a KOReader-compatible sync server to synchronize EPUB reading positions across devices.", + "koreaderSyncResumeInfo": "KOReader sync is enabled. Your reading position will be automatically restored from the sync server when you open this book.", + "editServerTitle": "Edit Server", "editServerNotFound": "Server not found", "editServerInfo": "Server Information", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 0d097e4..dbe3182 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -147,6 +147,23 @@ "addServerSuccess": "{name} añadido correctamente", "addServerError": "Error al añadir servidor: {error}", + "koreaderSyncToggle": "Sincronización KOReader API", + "koreaderSyncToggleSubtitle": "Sincronizar el progreso de lectura EPUB con un servidor compatible con KOReader", + "koreaderSyncUrl": "URL del servidor de sincronización", + "koreaderSyncUrlHint": "https://sync.ejemplo.com", + "koreaderSyncUsername": "Usuario de sincronización", + "koreaderSyncPassword": "Contraseña de sincronización", + "koreaderSyncTestConnection": "Probar conexión", + "koreaderSyncTestSuccess": "¡Conexión exitosa! Autenticado como {username}.", + "koreaderSyncTestFailed": "Error de conexión: {error}", + "koreaderSyncValidationUrlRequired": "La URL del servidor de sincronización es obligatoria cuando la sincronización KOReader está activada", + "koreaderSyncValidationUrlInvalid": "Introduce una URL válida", + "koreaderSyncValidationUsernameRequired": "El usuario de sincronización es obligatorio", + "koreaderSyncValidationPasswordRequired": "La contraseña de sincronización es obligatoria", + "koreaderSyncAuthFailed": "Sincronización KOReader: error de autenticación. Revisa las credenciales del servidor.", + "koreaderSyncHelp": "Conéctate a un servidor de sincronización compatible con KOReader para sincronizar las posiciones de lectura EPUB entre dispositivos.", + "koreaderSyncResumeInfo": "La sincronización KOReader está activada. Tu posición de lectura se restaurará automáticamente desde el servidor de sincronización al abrir este libro.", + "editServerTitle": "Editar servidor", "editServerNotFound": "Servidor no encontrado", "editServerInfo": "Información del servidor", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5f611fd..772c70d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -890,6 +890,102 @@ abstract class AppLocalizations { /// **'Error adding server: {error}'** String addServerError(String error); + /// No description provided for @koreaderSyncToggle. + /// + /// In en, this message translates to: + /// **'KOReader API Sync'** + String get koreaderSyncToggle; + + /// No description provided for @koreaderSyncToggleSubtitle. + /// + /// In en, this message translates to: + /// **'Sync EPUB reading progress with a KOReader-compatible server'** + String get koreaderSyncToggleSubtitle; + + /// No description provided for @koreaderSyncUrl. + /// + /// In en, this message translates to: + /// **'Sync Server URL'** + String get koreaderSyncUrl; + + /// No description provided for @koreaderSyncUrlHint. + /// + /// In en, this message translates to: + /// **'https://sync.example.com'** + String get koreaderSyncUrlHint; + + /// No description provided for @koreaderSyncUsername. + /// + /// In en, this message translates to: + /// **'Sync Username'** + String get koreaderSyncUsername; + + /// No description provided for @koreaderSyncPassword. + /// + /// In en, this message translates to: + /// **'Sync Password'** + String get koreaderSyncPassword; + + /// No description provided for @koreaderSyncTestConnection. + /// + /// In en, this message translates to: + /// **'Test Connection'** + String get koreaderSyncTestConnection; + + /// No description provided for @koreaderSyncTestSuccess. + /// + /// In en, this message translates to: + /// **'Connection successful! Authenticated as {username}.'** + String koreaderSyncTestSuccess(String username); + + /// No description provided for @koreaderSyncTestFailed. + /// + /// In en, this message translates to: + /// **'Connection failed: {error}'** + String koreaderSyncTestFailed(String error); + + /// No description provided for @koreaderSyncValidationUrlRequired. + /// + /// In en, this message translates to: + /// **'Sync server URL is required when KOReader sync is enabled'** + String get koreaderSyncValidationUrlRequired; + + /// No description provided for @koreaderSyncValidationUrlInvalid. + /// + /// In en, this message translates to: + /// **'Please enter a valid URL'** + String get koreaderSyncValidationUrlInvalid; + + /// No description provided for @koreaderSyncValidationUsernameRequired. + /// + /// In en, this message translates to: + /// **'Sync username is required'** + String get koreaderSyncValidationUsernameRequired; + + /// No description provided for @koreaderSyncValidationPasswordRequired. + /// + /// In en, this message translates to: + /// **'Sync password is required'** + String get koreaderSyncValidationPasswordRequired; + + /// No description provided for @koreaderSyncAuthFailed. + /// + /// In en, this message translates to: + /// **'KOReader sync: authentication failed. Check your server credentials.'** + String get koreaderSyncAuthFailed; + + /// No description provided for @koreaderSyncHelp. + /// + /// In en, this message translates to: + /// **'Connect to a KOReader-compatible sync server to synchronize EPUB reading positions across devices.'** + String get koreaderSyncHelp; + + /// No description provided for @koreaderSyncResumeInfo. + /// + /// In en, this message translates to: + /// **'KOReader sync is enabled. Your reading position will be automatically restored from the sync server when you open this book.'** + String get koreaderSyncResumeInfo; + /// No description provided for @editServerTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ae130e9..bc73ee7 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -428,6 +428,65 @@ class AppLocalizationsEn extends AppLocalizations { return 'Error adding server: $error'; } + @override + String get koreaderSyncToggle => 'KOReader API Sync'; + + @override + String get koreaderSyncToggleSubtitle => + 'Sync EPUB reading progress with a KOReader-compatible server'; + + @override + String get koreaderSyncUrl => 'Sync Server URL'; + + @override + String get koreaderSyncUrlHint => 'https://sync.example.com'; + + @override + String get koreaderSyncUsername => 'Sync Username'; + + @override + String get koreaderSyncPassword => 'Sync Password'; + + @override + String get koreaderSyncTestConnection => 'Test Connection'; + + @override + String koreaderSyncTestSuccess(String username) { + return 'Connection successful! Authenticated as $username.'; + } + + @override + String koreaderSyncTestFailed(String error) { + return 'Connection failed: $error'; + } + + @override + String get koreaderSyncValidationUrlRequired => + 'Sync server URL is required when KOReader sync is enabled'; + + @override + String get koreaderSyncValidationUrlInvalid => 'Please enter a valid URL'; + + @override + String get koreaderSyncValidationUsernameRequired => + 'Sync username is required'; + + @override + String get koreaderSyncValidationPasswordRequired => + 'Sync password is required'; + + @override + String get koreaderSyncAuthFailed => + 'KOReader sync: authentication failed. Check your server credentials.'; + + @override + String get koreaderSyncHelp => + 'Connect to a KOReader-compatible sync server to synchronize EPUB reading positions across devices.'; + + @override + String get koreaderSyncResumeInfo => + 'KOReader sync is enabled. Your reading position will be automatically restored from the sync server when you open this book.'; + @override String get editServerTitle => 'Edit Server'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index ecacfab..4e91378 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -436,6 +436,65 @@ class AppLocalizationsEs extends AppLocalizations { return 'Error al añadir servidor: $error'; } + @override + String get koreaderSyncToggle => 'Sincronización KOReader API'; + + @override + String get koreaderSyncToggleSubtitle => + 'Sincronizar el progreso de lectura EPUB con un servidor compatible con KOReader'; + + @override + String get koreaderSyncUrl => 'URL del servidor de sincronización'; + + @override + String get koreaderSyncUrlHint => 'https://sync.ejemplo.com'; + + @override + String get koreaderSyncUsername => 'Usuario de sincronización'; + + @override + String get koreaderSyncPassword => 'Contraseña de sincronización'; + + @override + String get koreaderSyncTestConnection => 'Probar conexión'; + + @override + String koreaderSyncTestSuccess(String username) { + return '¡Conexión exitosa! Autenticado como $username.'; + } + + @override + String koreaderSyncTestFailed(String error) { + return 'Error de conexión: $error'; + } + + @override + String get koreaderSyncValidationUrlRequired => + 'La URL del servidor de sincronización es obligatoria cuando la sincronización KOReader está activada'; + + @override + String get koreaderSyncValidationUrlInvalid => 'Introduce una URL válida'; + + @override + String get koreaderSyncValidationUsernameRequired => + 'El usuario de sincronización es obligatorio'; + + @override + String get koreaderSyncValidationPasswordRequired => + 'La contraseña de sincronización es obligatoria'; + + @override + String get koreaderSyncAuthFailed => + 'Sincronización KOReader: error de autenticación. Revisa las credenciales del servidor.'; + + @override + String get koreaderSyncHelp => + 'Conéctate a un servidor de sincronización compatible con KOReader para sincronizar las posiciones de lectura EPUB entre dispositivos.'; + + @override + String get koreaderSyncResumeInfo => + 'La sincronización KOReader está activada. Tu posición de lectura se restaurará automáticamente desde el servidor de sincronización al abrir este libro.'; + @override String get editServerTitle => 'Editar servidor'; diff --git a/lib/models/koreader_progress.dart b/lib/models/koreader_progress.dart new file mode 100644 index 0000000..37ddbce --- /dev/null +++ b/lib/models/koreader_progress.dart @@ -0,0 +1,60 @@ +/// Represents reading progress data from a KOReader sync server +class KoreaderProgress { + /// MD5 hash identifying the document + final String document; + + /// Reading progress percentage (0.0 to 1.0) + final double percentage; + + /// Position string (e.g., CFI for EPUB, page number for PDF) + final String progress; + + /// Device name that last updated the progress + final String device; + + /// Device identifier + final String? deviceId; + + /// Unix timestamp (seconds) of last update + final int? timestamp; + + const KoreaderProgress({ + required this.document, + required this.percentage, + required this.progress, + required this.device, + this.deviceId, + this.timestamp, + }); + + /// Create from JSON response + factory KoreaderProgress.fromJson(Map json) { + return KoreaderProgress( + document: json['document'] as String? ?? '', + percentage: (json['percentage'] as num?)?.toDouble() ?? 0.0, + progress: json['progress'] as String? ?? '', + device: json['device'] as String? ?? '', + deviceId: json['device_id'] as String?, + timestamp: json['timestamp'] as int?, + ); + } + + /// Convert to JSON for API request + Map toJson() { + return { + 'document': document, + 'percentage': percentage, + 'progress': progress, + 'device': device, + if (deviceId != null) 'device_id': deviceId, + }; + } + + /// Whether this progress has meaningful data + bool get isEmpty => document.isEmpty; + + /// Convert timestamp to DateTime + DateTime? get lastUpdated => timestamp != null + ? DateTime.fromMillisecondsSinceEpoch(timestamp! * 1000) + : null; +} diff --git a/lib/models/opds_server.dart b/lib/models/opds_server.dart index 3e7afe3..1122c94 100644 --- a/lib/models/opds_server.dart +++ b/lib/models/opds_server.dart @@ -28,6 +28,18 @@ class OPDSServer with _$OPDSServer { /// Timestamp of the last successful sync DateTime? lastSyncedAt, + + /// Whether KOReader sync is enabled for this server + @Default(false) bool koreaderSyncEnabled, + + /// KOReader sync server URL (e.g., https://sync.example.com) + String? koreaderSyncUrl, + + /// KOReader sync username + String? koreaderSyncUsername, + + /// KOReader sync password + String? koreaderSyncPassword, }) = _OPDSServer; /// Create OPDSServer from JSON @@ -46,6 +58,10 @@ class OPDSServer with _$OPDSServer { lastSyncedAt: row['last_synced_at'] != null ? DateTime.fromMillisecondsSinceEpoch(row['last_synced_at'] as int) : null, + koreaderSyncEnabled: (row['koreader_sync_enabled'] as int?) == 1, + koreaderSyncUrl: row['koreader_sync_url'] as String?, + koreaderSyncUsername: row['koreader_sync_username'] as String?, + koreaderSyncPassword: row['koreader_sync_password'] as String?, ); } } @@ -61,6 +77,10 @@ extension OPDSServerX on OPDSServer { 'password': password, 'created_at': createdAt.millisecondsSinceEpoch, 'last_synced_at': lastSyncedAt?.millisecondsSinceEpoch, + 'koreader_sync_enabled': koreaderSyncEnabled ? 1 : 0, + 'koreader_sync_url': koreaderSyncUrl, + 'koreader_sync_username': koreaderSyncUsername, + 'koreader_sync_password': koreaderSyncPassword, }; } diff --git a/lib/models/opds_server.freezed.dart b/lib/models/opds_server.freezed.dart index b8c5aea..cb6e77d 100644 --- a/lib/models/opds_server.freezed.dart +++ b/lib/models/opds_server.freezed.dart @@ -41,6 +41,18 @@ mixin _$OPDSServer { /// Timestamp of the last successful sync DateTime? get lastSyncedAt => throw _privateConstructorUsedError; + /// Whether KOReader sync is enabled for this server + bool get koreaderSyncEnabled => throw _privateConstructorUsedError; + + /// KOReader sync server URL (e.g., https://sync.example.com) + String? get koreaderSyncUrl => throw _privateConstructorUsedError; + + /// KOReader sync username + String? get koreaderSyncUsername => throw _privateConstructorUsedError; + + /// KOReader sync password + String? get koreaderSyncPassword => throw _privateConstructorUsedError; + /// Serializes this OPDSServer to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -64,7 +76,11 @@ abstract class $OPDSServerCopyWith<$Res> { String? username, String? password, DateTime createdAt, - DateTime? lastSyncedAt}); + DateTime? lastSyncedAt, + bool koreaderSyncEnabled, + String? koreaderSyncUrl, + String? koreaderSyncUsername, + String? koreaderSyncPassword}); } /// @nodoc @@ -89,6 +105,10 @@ class _$OPDSServerCopyWithImpl<$Res, $Val extends OPDSServer> Object? password = freezed, Object? createdAt = null, Object? lastSyncedAt = freezed, + Object? koreaderSyncEnabled = null, + Object? koreaderSyncUrl = freezed, + Object? koreaderSyncUsername = freezed, + Object? koreaderSyncPassword = freezed, }) { return _then(_value.copyWith( id: null == id @@ -119,6 +139,22 @@ class _$OPDSServerCopyWithImpl<$Res, $Val extends OPDSServer> ? _value.lastSyncedAt : lastSyncedAt // ignore: cast_nullable_to_non_nullable as DateTime?, + koreaderSyncEnabled: null == koreaderSyncEnabled + ? _value.koreaderSyncEnabled + : koreaderSyncEnabled // ignore: cast_nullable_to_non_nullable + as bool, + koreaderSyncUrl: freezed == koreaderSyncUrl + ? _value.koreaderSyncUrl + : koreaderSyncUrl // ignore: cast_nullable_to_non_nullable + as String?, + koreaderSyncUsername: freezed == koreaderSyncUsername + ? _value.koreaderSyncUsername + : koreaderSyncUsername // ignore: cast_nullable_to_non_nullable + as String?, + koreaderSyncPassword: freezed == koreaderSyncPassword + ? _value.koreaderSyncPassword + : koreaderSyncPassword // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val); } } @@ -138,7 +174,11 @@ abstract class _$$OPDSServerImplCopyWith<$Res> String? username, String? password, DateTime createdAt, - DateTime? lastSyncedAt}); + DateTime? lastSyncedAt, + bool koreaderSyncEnabled, + String? koreaderSyncUrl, + String? koreaderSyncUsername, + String? koreaderSyncPassword}); } /// @nodoc @@ -161,6 +201,10 @@ class __$$OPDSServerImplCopyWithImpl<$Res> Object? password = freezed, Object? createdAt = null, Object? lastSyncedAt = freezed, + Object? koreaderSyncEnabled = null, + Object? koreaderSyncUrl = freezed, + Object? koreaderSyncUsername = freezed, + Object? koreaderSyncPassword = freezed, }) { return _then(_$OPDSServerImpl( id: null == id @@ -191,6 +235,22 @@ class __$$OPDSServerImplCopyWithImpl<$Res> ? _value.lastSyncedAt : lastSyncedAt // ignore: cast_nullable_to_non_nullable as DateTime?, + koreaderSyncEnabled: null == koreaderSyncEnabled + ? _value.koreaderSyncEnabled + : koreaderSyncEnabled // ignore: cast_nullable_to_non_nullable + as bool, + koreaderSyncUrl: freezed == koreaderSyncUrl + ? _value.koreaderSyncUrl + : koreaderSyncUrl // ignore: cast_nullable_to_non_nullable + as String?, + koreaderSyncUsername: freezed == koreaderSyncUsername + ? _value.koreaderSyncUsername + : koreaderSyncUsername // ignore: cast_nullable_to_non_nullable + as String?, + koreaderSyncPassword: freezed == koreaderSyncPassword + ? _value.koreaderSyncPassword + : koreaderSyncPassword // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -205,7 +265,11 @@ class _$OPDSServerImpl implements _OPDSServer { this.username, this.password, required this.createdAt, - this.lastSyncedAt}); + this.lastSyncedAt, + this.koreaderSyncEnabled = false, + this.koreaderSyncUrl, + this.koreaderSyncUsername, + this.koreaderSyncPassword}); factory _$OPDSServerImpl.fromJson(Map json) => _$$OPDSServerImplFromJson(json); @@ -238,9 +302,26 @@ class _$OPDSServerImpl implements _OPDSServer { @override final DateTime? lastSyncedAt; + /// Whether KOReader sync is enabled for this server + @override + @JsonKey() + final bool koreaderSyncEnabled; + + /// KOReader sync server URL (e.g., https://sync.example.com) + @override + final String? koreaderSyncUrl; + + /// KOReader sync username + @override + final String? koreaderSyncUsername; + + /// KOReader sync password + @override + final String? koreaderSyncPassword; + @override String toString() { - return 'OPDSServer(id: $id, name: $name, url: $url, username: $username, password: $password, createdAt: $createdAt, lastSyncedAt: $lastSyncedAt)'; + return 'OPDSServer(id: $id, name: $name, url: $url, username: $username, password: $password, createdAt: $createdAt, lastSyncedAt: $lastSyncedAt, koreaderSyncEnabled: $koreaderSyncEnabled, koreaderSyncUrl: $koreaderSyncUrl, koreaderSyncUsername: $koreaderSyncUsername, koreaderSyncPassword: $koreaderSyncPassword)'; } @override @@ -258,13 +339,32 @@ class _$OPDSServerImpl implements _OPDSServer { (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && (identical(other.lastSyncedAt, lastSyncedAt) || - other.lastSyncedAt == lastSyncedAt)); + other.lastSyncedAt == lastSyncedAt) && + (identical(other.koreaderSyncEnabled, koreaderSyncEnabled) || + other.koreaderSyncEnabled == koreaderSyncEnabled) && + (identical(other.koreaderSyncUrl, koreaderSyncUrl) || + other.koreaderSyncUrl == koreaderSyncUrl) && + (identical(other.koreaderSyncUsername, koreaderSyncUsername) || + other.koreaderSyncUsername == koreaderSyncUsername) && + (identical(other.koreaderSyncPassword, koreaderSyncPassword) || + other.koreaderSyncPassword == koreaderSyncPassword)); } @JsonKey(includeFromJson: false, includeToJson: false) @override int get hashCode => Object.hash( - runtimeType, id, name, url, username, password, createdAt, lastSyncedAt); + runtimeType, + id, + name, + url, + username, + password, + createdAt, + lastSyncedAt, + koreaderSyncEnabled, + koreaderSyncUrl, + koreaderSyncUsername, + koreaderSyncPassword); /// Create a copy of OPDSServer /// with the given fields replaced by the non-null parameter values. @@ -290,7 +390,11 @@ abstract class _OPDSServer implements OPDSServer { final String? username, final String? password, required final DateTime createdAt, - final DateTime? lastSyncedAt}) = _$OPDSServerImpl; + final DateTime? lastSyncedAt, + final bool koreaderSyncEnabled, + final String? koreaderSyncUrl, + final String? koreaderSyncUsername, + final String? koreaderSyncPassword}) = _$OPDSServerImpl; factory _OPDSServer.fromJson(Map json) = _$OPDSServerImpl.fromJson; @@ -323,6 +427,22 @@ abstract class _OPDSServer implements OPDSServer { @override DateTime? get lastSyncedAt; + /// Whether KOReader sync is enabled for this server + @override + bool get koreaderSyncEnabled; + + /// KOReader sync server URL (e.g., https://sync.example.com) + @override + String? get koreaderSyncUrl; + + /// KOReader sync username + @override + String? get koreaderSyncUsername; + + /// KOReader sync password + @override + String? get koreaderSyncPassword; + /// Create a copy of OPDSServer /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/models/opds_server.g.dart b/lib/models/opds_server.g.dart index 7c26ad1..b9a8c96 100644 --- a/lib/models/opds_server.g.dart +++ b/lib/models/opds_server.g.dart @@ -17,6 +17,10 @@ _$OPDSServerImpl _$$OPDSServerImplFromJson(Map json) => lastSyncedAt: json['lastSyncedAt'] == null ? null : DateTime.parse(json['lastSyncedAt'] as String), + koreaderSyncEnabled: json['koreaderSyncEnabled'] as bool? ?? false, + koreaderSyncUrl: json['koreaderSyncUrl'] as String?, + koreaderSyncUsername: json['koreaderSyncUsername'] as String?, + koreaderSyncPassword: json['koreaderSyncPassword'] as String?, ); Map _$$OPDSServerImplToJson(_$OPDSServerImpl instance) => @@ -28,4 +32,8 @@ Map _$$OPDSServerImplToJson(_$OPDSServerImpl instance) => 'password': instance.password, 'createdAt': instance.createdAt.toIso8601String(), 'lastSyncedAt': instance.lastSyncedAt?.toIso8601String(), + 'koreaderSyncEnabled': instance.koreaderSyncEnabled, + 'koreaderSyncUrl': instance.koreaderSyncUrl, + 'koreaderSyncUsername': instance.koreaderSyncUsername, + 'koreaderSyncPassword': instance.koreaderSyncPassword, }; diff --git a/lib/models/reading_progress.dart b/lib/models/reading_progress.dart index 9e61ed0..65ed6c1 100644 --- a/lib/models/reading_progress.dart +++ b/lib/models/reading_progress.dart @@ -33,6 +33,9 @@ class ReadingProgress with _$ReadingProgress { /// Feed URL this entry was opened from (for "next in series" navigation) String? seriesFeedUrl, + + /// KOReader sync document hash (partial MD5 of the EPUB file) + String? syncHash, }) = _ReadingProgress; const ReadingProgress._(); @@ -54,12 +57,18 @@ class ReadingProgress with _$ReadingProgress { DateTime.fromMillisecondsSinceEpoch(row['last_read_at'] as int), publicationCacheId: row['publication_cache_id'] as String?, seriesFeedUrl: row['series_feed_url'] as String?, + syncHash: row['sync_hash'] as String?, ); } /// Calculate reading progress percentage double get progressPercentage { if (totalPages == 0) return 0.0; + // EPUB progress stores currentPage as a 0-100 percentage value directly, + // while OPDS-PS stores 0-indexed page numbers. + if (epubLocation != null) { + return currentPage / totalPages; + } return (currentPage + 1) / totalPages; } @@ -67,7 +76,12 @@ class ReadingProgress with _$ReadingProgress { bool get isStarted => currentPage > 0; /// Check if the publication is completed - bool get isCompleted => currentPage >= totalPages - 1; + bool get isCompleted { + if (epubLocation != null) { + return currentPage >= totalPages; + } + return currentPage >= totalPages - 1; + } } extension ReadingProgressX on ReadingProgress { @@ -83,6 +97,7 @@ extension ReadingProgressX on ReadingProgress { 'last_read_at': lastReadAt.millisecondsSinceEpoch, 'publication_cache_id': publicationCacheId, 'series_feed_url': seriesFeedUrl, + 'sync_hash': syncHash, }; } } diff --git a/lib/models/reading_progress.freezed.dart b/lib/models/reading_progress.freezed.dart index 22a34fe..b4edb42 100644 --- a/lib/models/reading_progress.freezed.dart +++ b/lib/models/reading_progress.freezed.dart @@ -47,6 +47,9 @@ mixin _$ReadingProgress { /// Feed URL this entry was opened from (for "next in series" navigation) String? get seriesFeedUrl => throw _privateConstructorUsedError; + /// KOReader sync document hash (partial MD5 of the EPUB file) + String? get syncHash => throw _privateConstructorUsedError; + /// Serializes this ReadingProgress to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -72,7 +75,8 @@ abstract class $ReadingProgressCopyWith<$Res> { String? epubLocation, DateTime lastReadAt, String? publicationCacheId, - String? seriesFeedUrl}); + String? seriesFeedUrl, + String? syncHash}); } /// @nodoc @@ -99,6 +103,7 @@ class _$ReadingProgressCopyWithImpl<$Res, $Val extends ReadingProgress> Object? lastReadAt = null, Object? publicationCacheId = freezed, Object? seriesFeedUrl = freezed, + Object? syncHash = freezed, }) { return _then(_value.copyWith( id: null == id @@ -137,6 +142,10 @@ class _$ReadingProgressCopyWithImpl<$Res, $Val extends ReadingProgress> ? _value.seriesFeedUrl : seriesFeedUrl // ignore: cast_nullable_to_non_nullable as String?, + syncHash: freezed == syncHash + ? _value.syncHash + : syncHash // ignore: cast_nullable_to_non_nullable + as String?, ) as $Val); } } @@ -158,7 +167,8 @@ abstract class _$$ReadingProgressImplCopyWith<$Res> String? epubLocation, DateTime lastReadAt, String? publicationCacheId, - String? seriesFeedUrl}); + String? seriesFeedUrl, + String? syncHash}); } /// @nodoc @@ -183,6 +193,7 @@ class __$$ReadingProgressImplCopyWithImpl<$Res> Object? lastReadAt = null, Object? publicationCacheId = freezed, Object? seriesFeedUrl = freezed, + Object? syncHash = freezed, }) { return _then(_$ReadingProgressImpl( id: null == id @@ -221,6 +232,10 @@ class __$$ReadingProgressImplCopyWithImpl<$Res> ? _value.seriesFeedUrl : seriesFeedUrl // ignore: cast_nullable_to_non_nullable as String?, + syncHash: freezed == syncHash + ? _value.syncHash + : syncHash // ignore: cast_nullable_to_non_nullable + as String?, )); } } @@ -237,7 +252,8 @@ class _$ReadingProgressImpl extends _ReadingProgress { this.epubLocation, required this.lastReadAt, this.publicationCacheId, - this.seriesFeedUrl}) + this.seriesFeedUrl, + this.syncHash}) : super._(); factory _$ReadingProgressImpl.fromJson(Map json) => @@ -279,9 +295,13 @@ class _$ReadingProgressImpl extends _ReadingProgress { @override final String? seriesFeedUrl; + /// KOReader sync document hash (partial MD5 of the EPUB file) + @override + final String? syncHash; + @override String toString() { - return 'ReadingProgress(id: $id, publicationId: $publicationId, serverId: $serverId, currentPage: $currentPage, totalPages: $totalPages, epubLocation: $epubLocation, lastReadAt: $lastReadAt, publicationCacheId: $publicationCacheId, seriesFeedUrl: $seriesFeedUrl)'; + return 'ReadingProgress(id: $id, publicationId: $publicationId, serverId: $serverId, currentPage: $currentPage, totalPages: $totalPages, epubLocation: $epubLocation, lastReadAt: $lastReadAt, publicationCacheId: $publicationCacheId, seriesFeedUrl: $seriesFeedUrl, syncHash: $syncHash)'; } @override @@ -305,7 +325,9 @@ class _$ReadingProgressImpl extends _ReadingProgress { (identical(other.publicationCacheId, publicationCacheId) || other.publicationCacheId == publicationCacheId) && (identical(other.seriesFeedUrl, seriesFeedUrl) || - other.seriesFeedUrl == seriesFeedUrl)); + other.seriesFeedUrl == seriesFeedUrl) && + (identical(other.syncHash, syncHash) || + other.syncHash == syncHash)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -320,7 +342,8 @@ class _$ReadingProgressImpl extends _ReadingProgress { epubLocation, lastReadAt, publicationCacheId, - seriesFeedUrl); + seriesFeedUrl, + syncHash); /// Create a copy of ReadingProgress /// with the given fields replaced by the non-null parameter values. @@ -349,7 +372,8 @@ abstract class _ReadingProgress extends ReadingProgress { final String? epubLocation, required final DateTime lastReadAt, final String? publicationCacheId, - final String? seriesFeedUrl}) = _$ReadingProgressImpl; + final String? seriesFeedUrl, + final String? syncHash}) = _$ReadingProgressImpl; const _ReadingProgress._() : super._(); factory _ReadingProgress.fromJson(Map json) = @@ -391,6 +415,10 @@ abstract class _ReadingProgress extends ReadingProgress { @override String? get seriesFeedUrl; + /// KOReader sync document hash (partial MD5 of the EPUB file) + @override + String? get syncHash; + /// Create a copy of ReadingProgress /// with the given fields replaced by the non-null parameter values. @override diff --git a/lib/models/reading_progress.g.dart b/lib/models/reading_progress.g.dart index 595d3d5..8537fec 100644 --- a/lib/models/reading_progress.g.dart +++ b/lib/models/reading_progress.g.dart @@ -18,6 +18,7 @@ _$ReadingProgressImpl _$$ReadingProgressImplFromJson( lastReadAt: DateTime.parse(json['lastReadAt'] as String), publicationCacheId: json['publicationCacheId'] as String?, seriesFeedUrl: json['seriesFeedUrl'] as String?, + syncHash: json['syncHash'] as String?, ); Map _$$ReadingProgressImplToJson( @@ -32,4 +33,5 @@ Map _$$ReadingProgressImplToJson( 'lastReadAt': instance.lastReadAt.toIso8601String(), 'publicationCacheId': instance.publicationCacheId, 'seriesFeedUrl': instance.seriesFeedUrl, + 'syncHash': instance.syncHash, }; diff --git a/lib/providers/koreader_sync_provider.dart b/lib/providers/koreader_sync_provider.dart new file mode 100644 index 0000000..face4f0 --- /dev/null +++ b/lib/providers/koreader_sync_provider.dart @@ -0,0 +1,81 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:worldhopper/models/koreader_progress.dart'; +import 'package:worldhopper/models/opds_server.dart'; +import 'package:worldhopper/providers/server_provider.dart'; +import 'package:worldhopper/services/koreader_sync_service.dart'; + +/// Provider for creating a KoreaderSyncService for a given server. +/// Only returns a service if the server has KOReader sync enabled. +final koreaderSyncServiceProvider = + Provider.family((ref, server) { + if (!server.koreaderSyncEnabled || + server.koreaderSyncUrl == null || + server.koreaderSyncUrl!.isEmpty) { + return null; + } + return KoreaderSyncService(server); +}); + +/// Provider to get a KoreaderSyncService by server ID. +/// Returns null if server not found or KOReader sync is not enabled. +final koreaderSyncServiceByIdProvider = + FutureProvider.family((ref, serverId) async { + final server = await ref.watch(serverProvider(serverId).future); + if (server == null) return null; + return ref.watch(koreaderSyncServiceProvider(server)); +}); + +/// Push reading progress to KOReader sync server. +/// Returns true if sync was successful, false otherwise. +/// Fails silently (logs error) to not interrupt the reading experience. +Future pushKoreaderProgress({ + required KoreaderSyncService syncService, + required String documentHash, + required double percentage, + required String progress, +}) async { + try { + return await syncService.updateProgress( + documentHash: documentHash, + percentage: percentage, + progress: progress, + ); + } on KoreaderSyncAuthException { + rethrow; + } catch (e) { + debugPrint('Failed to push KOReader progress: $e'); + return false; + } +} + +/// Pull reading progress from KOReader sync server. +/// Returns the remote progress if available, null otherwise. +Future pullKoreaderProgress({ + required KoreaderSyncService syncService, + required String documentHash, +}) async { + try { + return await syncService.getProgress(documentHash); + } on KoreaderSyncAuthException { + rethrow; + } catch (e) { + debugPrint('Failed to pull KOReader progress: $e'); + return null; + } +} + +/// Compute the document hash for a given entry. +/// If the EPUB file is available, uses the file's partial MD5 hash (compatible with KOReader). +/// Falls back to hashing the OPDS entry ID. +Future computeDocumentHash({ + required String entryId, + File? epubFile, +}) async { + if (epubFile != null && await epubFile.exists()) { + return await KoreaderSyncService.computeFileHash(epubFile); + } + return KoreaderSyncService.computeStringHash(entryId); +} diff --git a/lib/screens/publication/publication_detail_screen.dart b/lib/screens/publication/publication_detail_screen.dart index 3a1f2e8..b941f06 100644 --- a/lib/screens/publication/publication_detail_screen.dart +++ b/lib/screens/publication/publication_detail_screen.dart @@ -6,6 +6,7 @@ import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/config/constants.dart'; import 'package:worldhopper/models/opds_entry.dart'; import 'package:worldhopper/providers/reading_progress_provider.dart'; +import 'package:worldhopper/providers/server_provider.dart'; import 'package:worldhopper/providers/connectivity_provider.dart'; import 'package:worldhopper/models/reading_progress.dart'; import 'package:uuid/uuid.dart'; @@ -175,6 +176,12 @@ class PublicationDetailScreen extends ConsumerWidget { label: l10n.publicationPageOfTotal(lastRead + 1, pageCount), ); } + + // Show KOReader sync info for EPUBs when sync is enabled + if (entry.isEpub) { + return _buildKoreaderSyncInfo(context, ref); + } + return const SizedBox.shrink(); } @@ -193,6 +200,50 @@ class PublicationDetailScreen extends ConsumerWidget { ); } + /// Show an informational panel when KOReader sync is enabled, + /// telling the user that reading position will be restored automatically. + Widget _buildKoreaderSyncInfo(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final serverAsync = ref.watch(serverProvider(serverId)); + + return serverAsync.when( + data: (server) { + if (server == null || !server.koreaderSyncEnabled) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Card( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.sync, + color: Theme.of(context).colorScheme.primary, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.koreaderSyncResumeInfo, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), + ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) => const SizedBox.shrink(), + ); + } + Widget _buildProgressCardContent( BuildContext context, { required double percentage, diff --git a/lib/screens/reader/epub_reader_screen.dart b/lib/screens/reader/epub_reader_screen.dart index 22e4e90..2a61697 100644 --- a/lib/screens/reader/epub_reader_screen.dart +++ b/lib/screens/reader/epub_reader_screen.dart @@ -18,6 +18,8 @@ import 'package:worldhopper/providers/reading_progress_provider.dart'; import 'package:worldhopper/providers/server_provider.dart'; import 'package:worldhopper/providers/connectivity_provider.dart'; import 'package:worldhopper/services/epub_download_service.dart'; +import 'package:worldhopper/services/koreader_sync_service.dart'; +import 'package:worldhopper/providers/koreader_sync_provider.dart'; import 'package:uuid/uuid.dart'; import 'package:worldhopper/widgets/next_in_series_overlay.dart'; @@ -55,6 +57,12 @@ class _EpubReaderScreenState extends ConsumerState { bool _reachedEndThisSession = false; + // KOReader sync state + String? _koreaderDocumentHash; + KoreaderSyncService? _koreaderSyncService; + bool _hasSavedOnExit = false; + bool _koreaderAuthFailed = false; + // Gesture tracking for tap vs swipe detection Offset? _pointerDownPosition; DateTime? _pointerDownTime; @@ -66,12 +74,13 @@ class _EpubReaderScreenState extends ConsumerState { static const Duration _tapMaxDuration = Duration(milliseconds: 500); // taps complete quickly + bool _didStartLoad = false; + @override void initState() { super.initState(); // Create a new controller for each instance to avoid state retention _epubController = EpubController(); - _loadEpub(); // Keep screen awake while reading WakelockPlus.enable(); @@ -80,6 +89,18 @@ class _EpubReaderScreenState extends ConsumerState { SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // _loadEpub uses AppLocalizations.of(context) which requires inherited + // widgets to be available. initState() runs before they are ready, so + // we trigger the load here instead (guarded to run only once). + if (!_didStartLoad) { + _didStartLoad = true; + _loadEpub(); + } + } + Future _loadEpub() async { try { final l10n = AppLocalizations.of(context); @@ -162,6 +183,56 @@ class _EpubReaderScreenState extends ConsumerState { } } + // Always compute the document hash for storage (used by KOReader sync + // and shown in developer-mode raw data). + _koreaderDocumentHash = await computeDocumentHash( + entryId: widget.entry.id, + epubFile: file, + ); + + // KOReader sync: pull remote progress. + // Only restore from remote when there is NO local progress (i.e. + // cross-device resume). When local progress exists we always trust it + // because we are the ones who wrote it; the remote timestamp will + // often be slightly later (server-side) and would otherwise always + // win, overwriting the perfectly valid local CFI. + if (server.koreaderSyncEnabled) { + try { + final syncService = ref.read(koreaderSyncServiceProvider(server)); + if (syncService != null) { + _koreaderSyncService = syncService; + + // Only pull remote progress when we have no local position + if (cfi == null) { + final remoteProgress = await pullKoreaderProgress( + syncService: syncService, + documentHash: _koreaderDocumentHash!, + ); + + if (remoteProgress != null && + remoteProgress.progress.isNotEmpty && + _isValidEpubCfi(remoteProgress.progress)) { + cfi = remoteProgress.progress; + debugPrint('KOReader sync: restoring remote position ' + '(${remoteProgress.percentage * 100}%)'); + } else if (remoteProgress != null && + remoteProgress.progress.isNotEmpty) { + debugPrint('KOReader sync: ignoring non-CFI progress string: ' + '${remoteProgress.progress}'); + } + } + } + } on KoreaderSyncAuthException { + _koreaderAuthFailed = true; + if (mounted) { + final l10nSync = AppLocalizations.of(context); + context.showWarningSnackBar(l10nSync.koreaderSyncAuthFailed); + } + } catch (e) { + debugPrint('KOReader sync pull failed: $e'); + } + } + if (!mounted) return; final l10nFinal = AppLocalizations.of(context); setState(() { @@ -184,9 +255,23 @@ class _EpubReaderScreenState extends ConsumerState { } } + bool _hasReceivedNonZeroProgress = false; + /// Handle page navigation - save progress with CFI void _handleRelocated(EpubLocation location) { _lastLocation = location; + + // epub.js fires onRelocated immediately when restoring a saved position, + // but reports progress as 0.0 before it finishes calculating the actual + // value. Skip saving/pushing until we get a real progress value to avoid + // overwriting the stored position with 0%. + if (location.progress > 0.0) { + _hasReceivedNonZeroProgress = true; + } + if (!_hasReceivedNonZeroProgress) { + return; + } + if (location.progress >= 0.99 && !_reachedEndThisSession) { setState(() { _reachedEndThisSession = true; @@ -221,8 +306,16 @@ class _EpubReaderScreenState extends ConsumerState { serverId: widget.serverId, ); - final existingProgress = - await ref.read(readingProgressProvider(progressKey).future); + // Capture all ref-dependent values synchronously before any await. + // This is critical when called from dispose(), where ref becomes + // invalid after super.dispose(). + final existingProgressFuture = + ref.read(readingProgressProvider(progressKey).future); + final notifier = ref.read(readingProgressNotifierProvider.notifier); + final cachedPubFuture = ref.read( + cachedPublicationProvider(widget.serverId, widget.entry.id).future); + + final existingProgress = await existingProgressFuture; // Calculate current/total pages from location percentage final percentage = location.progress; @@ -240,8 +333,7 @@ class _EpubReaderScreenState extends ConsumerState { }); // Get cached publication - final cachedPub = await ref.read( - cachedPublicationProvider(widget.serverId, widget.entry.id).future); + final cachedPub = await cachedPubFuture; final progress = existingProgress?.copyWith( currentPage: currentPage, @@ -251,6 +343,7 @@ class _EpubReaderScreenState extends ConsumerState { publicationCacheId: cachedPub?.id ?? existingProgress.publicationCacheId, seriesFeedUrl: widget.feedUrl ?? existingProgress.seriesFeedUrl, + syncHash: _koreaderDocumentHash ?? existingProgress.syncHash, ) ?? ReadingProgress( id: const Uuid().v4(), @@ -262,14 +355,43 @@ class _EpubReaderScreenState extends ConsumerState { lastReadAt: DateTime.now(), publicationCacheId: cachedPub?.id, seriesFeedUrl: widget.feedUrl, + syncHash: _koreaderDocumentHash, ); - await ref - .read(readingProgressNotifierProvider.notifier) - .saveProgress(progress); + await notifier.saveProgress(progress); - ref.invalidate(readingProgressProvider(progressKey)); - ref.invalidate(recentlyReadProvider); + // Only invalidate providers if the widget is still mounted. + // During dispose(), ref is no longer valid for invalidation. + if (mounted) { + ref.invalidate(readingProgressProvider(progressKey)); + ref.invalidate(recentlyReadProvider); + } + + // Push to KOReader sync if enabled + if (_koreaderSyncService != null && _koreaderDocumentHash != null) { + // Round to 5 decimal places to match KOReader's precision + final syncPercentage = double.parse(percentage.toStringAsFixed(5)); + debugPrint( + 'KOReader sync: pushing progress $syncPercentage (${location.startCfi})'); + try { + final pushed = await pushKoreaderProgress( + syncService: _koreaderSyncService!, + documentHash: _koreaderDocumentHash!, + percentage: syncPercentage, + progress: location.startCfi, + ); + debugPrint('KOReader sync: push result=$pushed'); + } on KoreaderSyncAuthException { + if (!_koreaderAuthFailed && mounted) { + _koreaderAuthFailed = true; + final l10n = AppLocalizations.of(context); + context.showWarningSnackBar(l10n.koreaderSyncAuthFailed); + } + } + } else { + debugPrint( + 'KOReader sync: skipping push (service=${_koreaderSyncService != null}, hash=${_koreaderDocumentHash != null})'); + } } /// Build display settings based on orientation @@ -419,7 +541,8 @@ class _EpubReaderScreenState extends ConsumerState { return PopScope( canPop: true, onPopInvokedWithResult: (bool didPop, Object? result) { - if (didPop) { + if (didPop && !_hasSavedOnExit) { + _hasSavedOnExit = true; if (_reachedEndThisSession) { _deleteCompletedProgress(); } else if (_lastLocation != null) { @@ -431,42 +554,31 @@ class _EpubReaderScreenState extends ConsumerState { body: Listener( behavior: HitTestBehavior.translucent, onPointerDown: (event) { - // Record initial touch position and time _pointerDownPosition = event.position; _pointerDownTime = DateTime.now(); _isSwipe = false; }, onPointerMove: (event) { - // Track movement to detect swipes if (_pointerDownPosition != null) { final delta = (event.position - _pointerDownPosition!).distance; - - // If movement exceeds threshold, mark as swipe (not a tap) if (delta > _swipeThreshold) { _isSwipe = true; } } }, onPointerUp: (event) { - // Only toggle on tap (not swipe) if (_pointerDownTime != null && _pointerDownPosition != null) { final duration = DateTime.now().difference(_pointerDownTime!); - - // Check if this was a tap (minimal movement, quick release) if (!_isSwipe && duration < _tapMaxDuration) { - // Apply existing position check for app bar exclusion if (_showAppBar) { final appBarHeight = MediaQuery.of(context).padding.top + 56; if (event.position.dy < appBarHeight) { return; // Tap on app bar, don't toggle } } - _toggleAppBarVisibility(); } } - - // Reset tracking state _pointerDownPosition = null; _pointerDownTime = null; _isSwipe = false; @@ -483,18 +595,18 @@ class _EpubReaderScreenState extends ConsumerState { return SafeArea( child: EpubViewer( key: ValueKey( - '$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'), // Include spread in key to force recreation + '$_viewerKey-${shouldUseDualPage ? "dual" : "single"}'), epubController: _epubController!, epubSource: EpubSource.fromFile(_epubFile!), initialCfi: _initialCfi, displaySettings: _buildDisplaySettings(orientation), onEpubLoaded: () { debugPrint('EPUB loaded successfully'); + _disableEpubJsTapNavigation(); }, onChaptersLoaded: _handleChaptersLoaded, onRelocated: _handleRelocated, onTextSelected: (selection) { - // Future: Handle text selection for highlights/notes debugPrint('Selected: ${selection.selectedText}'); }, ), @@ -640,6 +752,53 @@ class _EpubReaderScreenState extends ConsumerState { } } + /// Disable epub.js tap-to-navigate so that taps only toggle the app bar. + /// Swipe gestures continue to work for page turning. + /// + /// The custom swipe handler uses touch events (touchstart/touchmove/touchend) + /// to detect swipes and calls rendition.next()/prev() from the touchend handler. + /// epub.js built-in navigation uses click events on content documents. + /// + /// Since taps generate: touchstart → touchend → click + /// and swipes generate: touchstart → touchmove → touchend (no click), + /// blocking click events in the content documents disables tap navigation + /// while keeping swipe navigation intact. + void _disableEpubJsTapNavigation() { + _epubController?.webViewController?.evaluateJavascript(source: ''' + (function() { + function blockClicks(doc) { + doc.addEventListener('click', function(e) { + if (e.target.closest && e.target.closest('a')) return; + e.stopPropagation(); + }, true); + } + + // Hook into new content loads (each chapter/spine item) + rendition.hooks.content.register(function(contents) { + if (contents.document) { + blockClicks(contents.document); + } + }); + + // Apply to already-loaded views + var views = rendition.views(); + if (views && views.forEach) { + views.forEach(function(view) { + if (view.document) blockClicks(view.document); + }); + } + })(); + '''); + } + + /// Check whether a progress string looks like a valid EPUB CFI. + /// KOReader devices may store XPointer-based progress strings (e.g. + /// "/body/DocFragment[20]/body/p[22]") which epub.js cannot parse. + /// We only accept strings that start with "epubcfi(". + bool _isValidEpubCfi(String value) { + return value.trimLeft().startsWith('epubcfi('); + } + /// Helper method to convert Flutter Color to CSS hex string String _colorToHex(Color color) { return '#${color.toARGB32().toRadixString(16).substring(2).toUpperCase()}'; @@ -795,12 +954,15 @@ class _EpubReaderScreenState extends ConsumerState { // Cancel timers _appBarAutoHideTimer?.cancel(); - // If the user finished the chapter, remove from recently read; - // otherwise save progress one final time before disposing. - if (_reachedEndThisSession) { - _deleteCompletedProgress(); - } else if (_lastLocation != null) { - _saveProgress(_lastLocation!); + // Save progress one final time if not already saved by onPopInvokedWithResult. + // The guard flag prevents double saves when both onPop and dispose fire. + if (!_hasSavedOnExit) { + _hasSavedOnExit = true; + if (_reachedEndThisSession) { + _deleteCompletedProgress(); + } else if (_lastLocation != null) { + _saveProgress(_lastLocation!); + } } // Allow screen to sleep again diff --git a/lib/screens/servers/add_server_screen.dart b/lib/screens/servers/add_server_screen.dart index a366d5d..6d8b463 100644 --- a/lib/screens/servers/add_server_screen.dart +++ b/lib/screens/servers/add_server_screen.dart @@ -6,6 +6,7 @@ import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/models/opds_server.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/providers/server_provider.dart'; +import 'package:worldhopper/services/koreader_sync_service.dart'; /// Screen for adding a new OPDS server class AddServerScreen extends ConsumerStatefulWidget { @@ -25,12 +26,23 @@ class _AddServerScreenState extends ConsumerState { bool _obscurePassword = true; bool _isLoading = false; + // KOReader sync fields + bool _koreaderSyncEnabled = false; + final _koreaderSyncUrlController = TextEditingController(); + final _koreaderSyncUsernameController = TextEditingController(); + final _koreaderSyncPasswordController = TextEditingController(); + bool _obscureKoreaderPassword = true; + bool _isTestingKoreaderConnection = false; + @override void dispose() { _nameController.dispose(); _urlController.dispose(); _usernameController.dispose(); _passwordController.dispose(); + _koreaderSyncUrlController.dispose(); + _koreaderSyncUsernameController.dispose(); + _koreaderSyncPasswordController.dispose(); super.dispose(); } @@ -152,7 +164,136 @@ class _AddServerScreenState extends ConsumerState { } return null; }, - onFieldSubmitted: (_) => _saveServer(), + ), + const SizedBox(height: 24), + ], + + // KOReader Sync toggle + SwitchListTile( + title: Text(l10n.koreaderSyncToggle), + subtitle: Text(l10n.koreaderSyncToggleSubtitle), + value: _koreaderSyncEnabled, + onChanged: (value) { + setState(() { + _koreaderSyncEnabled = value; + if (!value) { + _koreaderSyncUrlController.clear(); + _koreaderSyncUsernameController.clear(); + _koreaderSyncPasswordController.clear(); + } + }); + }, + ), + const SizedBox(height: 16), + + // KOReader sync fields (conditionally shown) + if (_koreaderSyncEnabled) ...[ + TextFormField( + controller: _koreaderSyncUrlController, + decoration: InputDecoration( + labelText: l10n.koreaderSyncUrl, + hintText: l10n.koreaderSyncUrlHint, + prefixIcon: const Icon(Icons.sync), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + validator: (value) { + if (_koreaderSyncEnabled && + (value == null || value.trim().isEmpty)) { + return l10n.koreaderSyncValidationUrlRequired; + } + if (value != null && value.trim().isNotEmpty) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || !uri.hasScheme || !uri.hasAuthority) { + return l10n.koreaderSyncValidationUrlInvalid; + } + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _koreaderSyncUsernameController, + decoration: InputDecoration( + labelText: l10n.koreaderSyncUsername, + prefixIcon: const Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (_koreaderSyncEnabled && + (value == null || value.trim().isEmpty)) { + return l10n.koreaderSyncValidationUsernameRequired; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _koreaderSyncPasswordController, + decoration: InputDecoration( + labelText: l10n.koreaderSyncPassword, + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscureKoreaderPassword + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscureKoreaderPassword = !_obscureKoreaderPassword; + }); + }, + ), + ), + obscureText: _obscureKoreaderPassword, + textInputAction: TextInputAction.done, + validator: (value) { + if (_koreaderSyncEnabled && + (value == null || value.isEmpty)) { + return l10n.koreaderSyncValidationPasswordRequired; + } + return null; + }, + ), + const SizedBox(height: 16), + // Test connection button + OutlinedButton.icon( + onPressed: _isTestingKoreaderConnection + ? null + : _testKoreaderConnection, + icon: _isTestingKoreaderConnection + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.wifi_tethering), + label: Text(l10n.koreaderSyncTestConnection), + ), + const SizedBox(height: 16), + // KOReader sync help text + Card( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.koreaderSyncHelp, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), ), const SizedBox(height: 24), ], @@ -199,6 +340,61 @@ class _AddServerScreenState extends ConsumerState { ); } + Future _testKoreaderConnection() async { + // Validate KOReader fields before testing + final url = _koreaderSyncUrlController.text.trim(); + final username = _koreaderSyncUsernameController.text.trim(); + final password = _koreaderSyncPasswordController.text; + + if (url.isEmpty || username.isEmpty || password.isEmpty) { + return; + } + + setState(() { + _isTestingKoreaderConnection = true; + }); + + try { + final l10n = AppLocalizations.of(context); + + // Create a temporary server to test with + final testServer = OPDSServer( + id: 'test', + name: 'test', + url: '', + createdAt: DateTime.now(), + koreaderSyncEnabled: true, + koreaderSyncUrl: url, + koreaderSyncUsername: username, + koreaderSyncPassword: password, + ); + + final syncService = KoreaderSyncService(testServer); + + // Test authentication (also verifies server is reachable) + final authenticated = await syncService.authenticate(); + if (mounted) { + if (authenticated) { + context.showSuccessSnackBar(l10n.koreaderSyncTestSuccess(username)); + } else { + context + .showErrorSnackBar(l10n.koreaderSyncTestFailed('Unauthorized')); + } + } + } catch (e) { + if (mounted) { + final l10n = AppLocalizations.of(context); + context.showErrorSnackBar(l10n.koreaderSyncTestFailed(e.toString())); + } + } finally { + if (mounted) { + setState(() { + _isTestingKoreaderConnection = false; + }); + } + } + } + Future _saveServer() async { if (!_formKey.currentState!.validate()) { return; @@ -234,6 +430,15 @@ class _AddServerScreenState extends ConsumerState { username: _requiresAuth ? _usernameController.text.trim() : null, password: _requiresAuth ? _passwordController.text : null, createdAt: DateTime.now(), + koreaderSyncEnabled: _koreaderSyncEnabled, + koreaderSyncUrl: _koreaderSyncEnabled + ? _koreaderSyncUrlController.text.trim() + : null, + koreaderSyncUsername: _koreaderSyncEnabled + ? _koreaderSyncUsernameController.text.trim() + : null, + koreaderSyncPassword: + _koreaderSyncEnabled ? _koreaderSyncPasswordController.text : null, ); // Save server diff --git a/lib/screens/servers/edit_server_screen.dart b/lib/screens/servers/edit_server_screen.dart index 9632104..48f8bc6 100644 --- a/lib/screens/servers/edit_server_screen.dart +++ b/lib/screens/servers/edit_server_screen.dart @@ -5,6 +5,7 @@ import 'package:worldhopper/l10n/app_localizations.dart'; import 'package:worldhopper/helpers/snackbar_helper.dart'; import 'package:worldhopper/models/opds_server.dart'; import 'package:worldhopper/providers/server_provider.dart'; +import 'package:worldhopper/services/koreader_sync_service.dart'; /// Screen for editing an existing OPDS server class EditServerScreen extends ConsumerStatefulWidget { @@ -30,12 +31,23 @@ class _EditServerScreenState extends ConsumerState { bool _isLoading = false; OPDSServer? _originalServer; + // KOReader sync fields + bool _koreaderSyncEnabled = false; + final _koreaderSyncUrlController = TextEditingController(); + final _koreaderSyncUsernameController = TextEditingController(); + final _koreaderSyncPasswordController = TextEditingController(); + bool _obscureKoreaderPassword = true; + bool _isTestingKoreaderConnection = false; + @override void dispose() { _nameController.dispose(); _urlController.dispose(); _usernameController.dispose(); _passwordController.dispose(); + _koreaderSyncUrlController.dispose(); + _koreaderSyncUsernameController.dispose(); + _koreaderSyncPasswordController.dispose(); super.dispose(); } @@ -68,6 +80,19 @@ class _EditServerScreenState extends ConsumerState { if (server.password != null) { _passwordController.text = server.password!; } + // Initialize KOReader sync fields + _koreaderSyncEnabled = server.koreaderSyncEnabled; + if (server.koreaderSyncUrl != null) { + _koreaderSyncUrlController.text = server.koreaderSyncUrl!; + } + if (server.koreaderSyncUsername != null) { + _koreaderSyncUsernameController.text = + server.koreaderSyncUsername!; + } + if (server.koreaderSyncPassword != null) { + _koreaderSyncPasswordController.text = + server.koreaderSyncPassword!; + } } return Form( @@ -178,7 +203,140 @@ class _EditServerScreenState extends ConsumerState { } return null; }, - onFieldSubmitted: (_) => _saveServer(), + ), + const SizedBox(height: 24), + ], + + // KOReader Sync toggle + SwitchListTile( + title: Text(l10n.koreaderSyncToggle), + subtitle: Text(l10n.koreaderSyncToggleSubtitle), + value: _koreaderSyncEnabled, + onChanged: (value) { + setState(() { + _koreaderSyncEnabled = value; + if (!value) { + _koreaderSyncUrlController.clear(); + _koreaderSyncUsernameController.clear(); + _koreaderSyncPasswordController.clear(); + } + }); + }, + ), + const SizedBox(height: 16), + + // KOReader sync fields (conditionally shown) + if (_koreaderSyncEnabled) ...[ + TextFormField( + controller: _koreaderSyncUrlController, + decoration: InputDecoration( + labelText: l10n.koreaderSyncUrl, + hintText: l10n.koreaderSyncUrlHint, + prefixIcon: const Icon(Icons.sync), + ), + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + validator: (value) { + if (_koreaderSyncEnabled && + (value == null || value.trim().isEmpty)) { + return l10n.koreaderSyncValidationUrlRequired; + } + if (value != null && value.trim().isNotEmpty) { + final uri = Uri.tryParse(value.trim()); + if (uri == null || + !uri.hasScheme || + !uri.hasAuthority) { + return l10n.koreaderSyncValidationUrlInvalid; + } + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _koreaderSyncUsernameController, + decoration: InputDecoration( + labelText: l10n.koreaderSyncUsername, + prefixIcon: const Icon(Icons.person_outline), + ), + textInputAction: TextInputAction.next, + validator: (value) { + if (_koreaderSyncEnabled && + (value == null || value.trim().isEmpty)) { + return l10n.koreaderSyncValidationUsernameRequired; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _koreaderSyncPasswordController, + decoration: InputDecoration( + labelText: l10n.koreaderSyncPassword, + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscureKoreaderPassword + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _obscureKoreaderPassword = + !_obscureKoreaderPassword; + }); + }, + ), + ), + obscureText: _obscureKoreaderPassword, + textInputAction: TextInputAction.done, + validator: (value) { + if (_koreaderSyncEnabled && + (value == null || value.isEmpty)) { + return l10n.koreaderSyncValidationPasswordRequired; + } + return null; + }, + ), + const SizedBox(height: 16), + // Test connection button + OutlinedButton.icon( + onPressed: _isTestingKoreaderConnection + ? null + : _testKoreaderConnection, + icon: _isTestingKoreaderConnection + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.wifi_tethering), + label: Text(l10n.koreaderSyncTestConnection), + ), + const SizedBox(height: 16), + // KOReader sync help text + Card( + color: + Theme.of(context).colorScheme.surfaceContainerHighest, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.info_outline, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + l10n.koreaderSyncHelp, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + ), ), const SizedBox(height: 24), ], @@ -273,6 +431,59 @@ class _EditServerScreenState extends ConsumerState { '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; } + Future _testKoreaderConnection() async { + final url = _koreaderSyncUrlController.text.trim(); + final username = _koreaderSyncUsernameController.text.trim(); + final password = _koreaderSyncPasswordController.text; + + if (url.isEmpty || username.isEmpty || password.isEmpty) { + return; + } + + setState(() { + _isTestingKoreaderConnection = true; + }); + + try { + final l10n = AppLocalizations.of(context); + + final testServer = OPDSServer( + id: 'test', + name: 'test', + url: '', + createdAt: DateTime.now(), + koreaderSyncEnabled: true, + koreaderSyncUrl: url, + koreaderSyncUsername: username, + koreaderSyncPassword: password, + ); + + final syncService = KoreaderSyncService(testServer); + + // Test authentication (also verifies server is reachable) + final authenticated = await syncService.authenticate(); + if (mounted) { + if (authenticated) { + context.showSuccessSnackBar(l10n.koreaderSyncTestSuccess(username)); + } else { + context + .showErrorSnackBar(l10n.koreaderSyncTestFailed('Unauthorized')); + } + } + } catch (e) { + if (mounted) { + final l10n = AppLocalizations.of(context); + context.showErrorSnackBar(l10n.koreaderSyncTestFailed(e.toString())); + } + } finally { + if (mounted) { + setState(() { + _isTestingKoreaderConnection = false; + }); + } + } + } + Future _saveServer() async { if (!_formKey.currentState!.validate()) { return; @@ -291,6 +502,15 @@ class _EditServerScreenState extends ConsumerState { url: _urlController.text.trim(), username: _requiresAuth ? _usernameController.text.trim() : null, password: _requiresAuth ? _passwordController.text : null, + koreaderSyncEnabled: _koreaderSyncEnabled, + koreaderSyncUrl: _koreaderSyncEnabled + ? _koreaderSyncUrlController.text.trim() + : null, + koreaderSyncUsername: _koreaderSyncEnabled + ? _koreaderSyncUsernameController.text.trim() + : null, + koreaderSyncPassword: + _koreaderSyncEnabled ? _koreaderSyncPasswordController.text : null, ); // Save server diff --git a/lib/services/koreader_sync_service.dart b/lib/services/koreader_sync_service.dart new file mode 100644 index 0000000..03f7931 --- /dev/null +++ b/lib/services/koreader_sync_service.dart @@ -0,0 +1,226 @@ +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(); +} diff --git a/lib/widgets/recently_read_card.dart b/lib/widgets/recently_read_card.dart index e46f0f1..b4fefb6 100644 --- a/lib/widgets/recently_read_card.dart +++ b/lib/widgets/recently_read_card.dart @@ -28,6 +28,10 @@ class RecentlyReadCard extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = AppLocalizations.of(context); + // Watch developer mode so the provider stays alive and its async-loaded + // value is available when the context menu is opened. + final isDeveloperMode = ref.watch(developerModeNotifierProvider); + // Load cached publication final publicationAsync = ref.watch( cachedPublicationProvider(progress.serverId, progress.publicationId), @@ -53,8 +57,8 @@ class RecentlyReadCard extends ConsumerWidget { // Display with full metadata return GestureDetector( onTap: () => _handleTap(context, ref, publication), - onLongPressStart: (details) => - _showContextMenu(context, ref, details, publication), + onLongPressStart: (details) => _showContextMenu( + context, ref, details, publication, isDeveloperMode), child: Card( clipBehavior: Clip.antiAlias, child: Column( @@ -284,6 +288,7 @@ class RecentlyReadCard extends ConsumerWidget { WidgetRef ref, LongPressStartDetails details, dynamic publication, + bool isDeveloperMode, ) async { final l10n = AppLocalizations.of(context); final RenderBox overlay = @@ -293,8 +298,6 @@ class RecentlyReadCard extends ConsumerWidget { Offset.zero & overlay.size, ); - final isDeveloperMode = ref.read(developerModeNotifierProvider); - final items = >[]; if (isDeveloperMode) { @@ -419,7 +422,13 @@ class RecentlyReadCard extends ConsumerWidget { body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: SelectableText( - '// ReadingProgress\n$progressJson\n\n// Publication\n$publicationJson', + [ + '// ReadingProgress', + progressJson, + '', + '// Publication', + publicationJson, + ].join('\n'), style: const TextStyle( fontFamily: 'monospace', fontSize: 12, diff --git a/pubspec.lock b/pubspec.lock index 59e7b0c..bba0a3c 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,7 +218,7 @@ packages: source: hosted version: "3.1.2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/pubspec.yaml b/pubspec.yaml index 0e73acc..4a191e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -72,6 +72,9 @@ dependencies: # Encryption for credentials encrypt: ^5.0.3 + # Cryptographic hashing (MD5 for KOReader sync document IDs) + crypto: ^3.0.3 + # Icons cupertino_icons: ^1.0.8