feat: koreader sync #4

Open
fmartingr wants to merge 2 commits from feat/koreader-sync into master
25 changed files with 1540 additions and 55 deletions

View file

@ -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;

View file

@ -22,6 +22,18 @@ final Map<int, Future<void> 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

View file

@ -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)

View file

@ -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
)
''';

View file

@ -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",

View file

@ -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",

View file

@ -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:

View file

@ -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';

View file

@ -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';

View file

@ -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<String, dynamic> 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<String, dynamic> 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;
}

View file

@ -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,
};
}

View file

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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

View file

@ -17,6 +17,10 @@ _$OPDSServerImpl _$$OPDSServerImplFromJson(Map<String, dynamic> 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<String, dynamic> _$$OPDSServerImplToJson(_$OPDSServerImpl instance) =>
@ -28,4 +32,8 @@ Map<String, dynamic> _$$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,
};

View file

@ -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,
};
}
}

View file

@ -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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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

View file

@ -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<String, dynamic> _$$ReadingProgressImplToJson(
@ -32,4 +33,5 @@ Map<String, dynamic> _$$ReadingProgressImplToJson(
'lastReadAt': instance.lastReadAt.toIso8601String(),
'publicationCacheId': instance.publicationCacheId,
'seriesFeedUrl': instance.seriesFeedUrl,
'syncHash': instance.syncHash,
};

View file

@ -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<KoreaderSyncService?, OPDSServer>((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<KoreaderSyncService?, String>((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<bool> 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<KoreaderProgress?> 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<String> computeDocumentHash({
required String entryId,
File? epubFile,
}) async {
if (epubFile != null && await epubFile.exists()) {
return await KoreaderSyncService.computeFileHash(epubFile);
}
return KoreaderSyncService.computeStringHash(entryId);
}

View file

@ -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,

View file

@ -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<EpubReaderScreen> {
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<EpubReaderScreen> {
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<EpubReaderScreen> {
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<void> _loadEpub() async {
try {
final l10n = AppLocalizations.of(context);
@ -162,6 +183,56 @@ class _EpubReaderScreenState extends ConsumerState<EpubReaderScreen> {
}
}
// 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<EpubReaderScreen> {
}
}
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<EpubReaderScreen> {
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<EpubReaderScreen> {
});
// 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<EpubReaderScreen> {
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<EpubReaderScreen> {
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<EpubReaderScreen> {
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<EpubReaderScreen> {
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<EpubReaderScreen> {
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<EpubReaderScreen> {
}
}
/// 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<EpubReaderScreen> {
// 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

View file

@ -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<AddServerScreen> {
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<AddServerScreen> {
}
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<AddServerScreen> {
);
}
Future<void> _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<void> _saveServer() async {
if (!_formKey.currentState!.validate()) {
return;
@ -234,6 +430,15 @@ class _AddServerScreenState extends ConsumerState<AddServerScreen> {
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

View file

@ -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<EditServerScreen> {
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<EditServerScreen> {
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<EditServerScreen> {
}
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<EditServerScreen> {
'${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}';
}
Future<void> _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<void> _saveServer() async {
if (!_formKey.currentState!.validate()) {
return;
@ -291,6 +502,15 @@ class _EditServerScreenState extends ConsumerState<EditServerScreen> {
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

View file

@ -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<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();
}

View file

@ -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 = <PopupMenuEntry<String>>[];
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,

View file

@ -218,7 +218,7 @@ packages:
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf

View file

@ -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