Skip to content

Commit aaeda54

Browse files
authored
Restore in-progress books (#69)
* feat(library): add listItems/listAllItems for mirror pull * refactor(library): dispatch listAllItems on IO; cover since branch * feat(sync): expose applyProgressItems for direct progress application * feat(restore): add RestoreInProgressUseCase join/filter logic * test(restore): assert applyPositions covers in-progress items absent from mirror * feat(restore): wire RestoreInProgressUseCase in AppContainer * fix(restore): don't fetch cover from epub href in restore wiring * feat(settings): add Restore in-progress books button * refactor(settings): set restore-running flag before launch * feat(library): empty-state Restore in-progress books prompt * refactor(library): align canRestore sharing + empty-state snackbar placement * fix(restore): delete temp epub if post-download insert fails
1 parent 0f8ad68 commit aaeda54

15 files changed

Lines changed: 783 additions & 10 deletions

File tree

app/src/main/java/io/theficos/ereader/di/AppContainer.kt

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package io.theficos.ereader.di
22

33
import android.content.Context
44
import io.theficos.ereader.auth.CalibreCredentialStore
5+
import io.theficos.ereader.core.identity.extractIdentity
6+
import io.theficos.ereader.core.metadata.readOpfBundle
57
import io.theficos.ereader.core.model.Document
68
import io.theficos.ereader.core.model.DocumentIdentity
79
import io.theficos.ereader.data.ai.AiClient
@@ -16,13 +18,15 @@ import io.theficos.ereader.data.library.sync.LibraryMirrorPushScheduler
1618
import io.theficos.ereader.data.local.DocumentRepository
1719
import io.theficos.ereader.data.local.ProgressRepository
1820
import io.theficos.ereader.data.local.db.EReaderDatabase
21+
import io.theficos.ereader.data.local.db.ProgressDao
1922
import io.theficos.ereader.data.opds.BookDownloader
2023
import io.theficos.ereader.data.opds.OpdsClient
2124
import io.theficos.ereader.data.opds.OpdsHttpClient
2225
import io.theficos.ereader.data.sync.SyncClient
2326
import io.theficos.ereader.data.sync.SyncDependencies
2427
import io.theficos.ereader.data.sync.SyncEnqueuer
2528
import io.theficos.ereader.data.sync.SyncOrchestrator
29+
import io.theficos.ereader.domain.restore.RestoreInProgressUseCase
2630
import io.theficos.ereader.reader.ReaderPreferencesStore
2731
import io.theficos.ereader.reader.ReadiumFactory
2832
import io.theficos.ereader.sideload.SideloadImporter
@@ -49,7 +53,6 @@ import kotlinx.coroutines.flow.map
4953
import kotlinx.coroutines.flow.stateIn
5054
import kotlinx.coroutines.launch
5155
import kotlinx.coroutines.withContext
52-
import io.theficos.ereader.data.local.db.ProgressDao
5356

5457
/**
5558
* URL that sync/library/AI clients should target. For [AccountCredentials.Basic]
@@ -264,6 +267,65 @@ class AppContainer(context: Context) {
264267
},
265268
)
266269

270+
/**
271+
* Restore-after-reinstall use case (Tasks 1–4). Assembles
272+
* [RestoreInProgressUseCase] with the real OPDS/sync/library/Room
273+
* collaborators. Unlike the catalog download path this deliberately
274+
* does NOT re-upload to /library/v1/items: the server mirror already
275+
* holds the row we joined on, so a re-upload would be redundant.
276+
*
277+
* Best-effort throughout: a missing mirror, an offline/401 progress
278+
* pull, or a single failed download must not abort the whole run.
279+
* Callers (Tasks 5/6) own when this fires.
280+
*/
281+
fun restoreInProgressUseCase(): RestoreInProgressUseCase = RestoreInProgressUseCase(
282+
fetchLibraryItems = {
283+
libraryClient.listAllItems(
284+
onTruncated = { n -> android.util.Log.w("Restore", "library mirror truncated at $n items") },
285+
)
286+
},
287+
fetchInProgress = {
288+
when (val res = syncClient.pullProgress(java.time.Instant.EPOCH.toString())) {
289+
is io.theficos.ereader.data.sync.SyncResult.Success -> res.value.items
290+
else -> emptyList() // best-effort: offline / 401 -> nothing to restore
291+
}
292+
},
293+
isPresent = { identity -> documentRepository.findByIdentity(identity) != null },
294+
downloadAndInsert = { c ->
295+
val fileName = "${java.util.UUID.randomUUID()}.epub"
296+
val file = bookDownloader.download(c.opdsHref, fileName) { _, _ -> }
297+
val coverFile: java.io.File? = null
298+
// If identity extraction / OPF read / insert throws after the bytes
299+
// landed, delete the temp file before rethrowing so a re-run (this
300+
// feature is explicitly re-runnable) doesn't accumulate orphans.
301+
try {
302+
val identity = extractIdentity(file)
303+
if (documentRepository.findByIdentity(identity) != null) {
304+
file.delete()
305+
coverFile?.delete()
306+
} else {
307+
val opf = readOpfBundle(file, fallbackTitle = c.title)
308+
documentRepository.insert(
309+
identity = identity,
310+
title = c.title,
311+
author = c.authors.firstOrNull(),
312+
downloadUrl = c.opdsHref,
313+
localPath = file.absolutePath,
314+
coverPath = coverFile?.absolutePath,
315+
downloadedAt = System.currentTimeMillis(),
316+
seriesName = opf.seriesName,
317+
seriesIndex = opf.seriesPosition?.toDouble(),
318+
)
319+
}
320+
} catch (t: Throwable) {
321+
file.delete()
322+
coverFile?.delete()
323+
throw t
324+
}
325+
},
326+
applyPositions = { items -> syncOrchestrator.applyProgressItems(items) },
327+
)
328+
267329
private suspend fun readOpfBytes(doc: Document): ByteArray? = withContext(Dispatchers.IO) {
268330
runCatching {
269331
java.util.zip.ZipFile(doc.localPath).use { zip ->
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package io.theficos.ereader.domain.restore
2+
3+
import io.theficos.ereader.core.model.DocumentIdentity
4+
import io.theficos.ereader.data.library.LibraryItemResponse
5+
import io.theficos.ereader.data.sync.ProgressItemDto
6+
7+
/** Outcome counters for one restore run, rendered as a snackbar summary. */
8+
data class RestoreSummary(
9+
val requested: Int,
10+
val downloaded: Int,
11+
val skippedExisting: Int,
12+
val skippedUnfetchable: Int,
13+
val failed: Int,
14+
)
15+
16+
/** One book selected for restore: an in-progress position joined to its mirror row. */
17+
data class RestoreCandidate(
18+
val progress: ProgressItemDto,
19+
val opdsHref: String,
20+
val title: String,
21+
val authors: List<String>,
22+
) {
23+
val identity: DocumentIdentity
24+
get() = DocumentIdentity(
25+
metadataId = progress.document.metadataId,
26+
contentHash = progress.document.contentHash,
27+
identityHashVersion = progress.document.identityHashVersion,
28+
)
29+
}
30+
31+
/**
32+
* Best-effort restore of in-progress books after a fresh install / data wipe.
33+
*
34+
* Collaborators are injected as suspend lambdas so the join/filter logic is
35+
* unit-testable without OkHttp/Room. Real wiring lives in [AppContainer].
36+
*
37+
* Flow: pull mirror -> pull progress -> keep in-progress (not finished, not
38+
* abandoned, percent > [minPercentExclusive]) -> join on content_hash for the
39+
* download href -> per book: skip if unfetchable or already present, else
40+
* download+insert -> finally apply the pulled positions so restored books open
41+
* where the user left off.
42+
*/
43+
class RestoreInProgressUseCase(
44+
private val fetchLibraryItems: suspend () -> List<LibraryItemResponse>,
45+
private val fetchInProgress: suspend () -> List<ProgressItemDto>,
46+
private val isPresent: suspend (DocumentIdentity) -> Boolean,
47+
private val downloadAndInsert: suspend (RestoreCandidate) -> Unit,
48+
private val applyPositions: suspend (List<ProgressItemDto>) -> Unit,
49+
private val minPercentExclusive: Double = 0.0,
50+
) {
51+
suspend fun run(): RestoreSummary {
52+
val mirrorByHash = fetchLibraryItems().associateBy { it.contentHash }
53+
val inProgress = fetchInProgress().filter {
54+
it.finishedAt == null && it.abandonedAt == null && it.percent > minPercentExclusive
55+
}
56+
val candidates = inProgress.mapNotNull { p ->
57+
val item = mirrorByHash[p.document.contentHash] ?: return@mapNotNull null
58+
val href = item.opdsHref ?: return@mapNotNull null
59+
RestoreCandidate(progress = p, opdsHref = href, title = item.title, authors = item.authors)
60+
}
61+
62+
var downloaded = 0
63+
var skippedExisting = 0
64+
var skippedUnfetchable = 0
65+
var failed = 0
66+
for (c in candidates) {
67+
when {
68+
!isHttp(c.opdsHref) -> skippedUnfetchable++
69+
isPresent(c.identity) -> skippedExisting++
70+
else -> try {
71+
downloadAndInsert(c)
72+
downloaded++
73+
} catch (t: Throwable) {
74+
failed++
75+
}
76+
}
77+
}
78+
79+
// Restore positions for every in-progress item; items whose document
80+
// isn't present are silently skipped inside applyPositions. Best-effort:
81+
// a failure here must not flip an otherwise-successful restore.
82+
runCatching { applyPositions(inProgress) }
83+
84+
return RestoreSummary(
85+
requested = candidates.size,
86+
downloaded = downloaded,
87+
skippedExisting = skippedExisting,
88+
skippedUnfetchable = skippedUnfetchable,
89+
failed = failed,
90+
)
91+
}
92+
93+
private fun isHttp(href: String): Boolean =
94+
href.startsWith("http://") || href.startsWith("https://")
95+
}

app/src/main/java/io/theficos/ereader/ui/AppNavGraph.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ fun AppNavGraph(container: AppContainer) {
111111
syncOrchestrator = container.syncOrchestrator,
112112
booksDir = container.booksDir,
113113
libraryPreferencesStore = container.libraryPreferencesStore,
114+
credentialStore = container.credentialStore,
115+
restoreInProgress = { container.restoreInProgressUseCase().run() },
114116
)
115117
}
116118
val catVm = remember {
@@ -140,6 +142,7 @@ fun AppNavGraph(container: AppContainer) {
140142
aiRepository = container.aiRepository,
141143
insightSyncRepository = container.insightSyncRepository,
142144
insightDao = container.insightDao,
145+
restoreInProgress = { container.restoreInProgressUseCase().run() },
143146
)
144147
}
145148
val aiConfig by container.aiRepository.config.collectAsState()

app/src/main/java/io/theficos/ereader/ui/library/LibraryScreen.kt

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import androidx.compose.foundation.layout.Box
77
import androidx.compose.foundation.layout.Column
88
import androidx.compose.foundation.layout.PaddingValues
99
import androidx.compose.foundation.layout.Row
10+
import androidx.compose.foundation.layout.Spacer
1011
import androidx.compose.foundation.layout.aspectRatio
12+
import androidx.compose.foundation.layout.height
1113
import androidx.compose.foundation.layout.fillMaxSize
1214
import androidx.compose.foundation.layout.fillMaxWidth
1315
import androidx.compose.foundation.layout.padding
@@ -64,6 +66,7 @@ import androidx.compose.ui.Modifier
6466
import androidx.compose.ui.platform.LocalContext
6567
import androidx.compose.ui.text.input.ImeAction
6668
import androidx.compose.ui.text.input.KeyboardCapitalization
69+
import androidx.compose.ui.text.style.TextAlign
6770
import androidx.compose.ui.text.style.TextOverflow
6871
import androidx.compose.ui.unit.dp
6972
import io.theficos.ereader.core.model.Document
@@ -122,13 +125,21 @@ fun LibraryScreen(
122125
val scope = rememberCoroutineScope()
123126
var searchActive by rememberSaveable { mutableStateOf(false) }
124127
val query by viewModel.query.collectAsState()
128+
val canRestore by viewModel.canRestore.collectAsState()
129+
val restoreRunning by viewModel.restoreRunning.collectAsState()
125130

126131
LaunchedEffect(Unit) {
127132
viewModel.events.collect { event ->
128-
when (event) {
129-
LibraryEvent.RestartFailed ->
130-
snackbarHostState.showSnackbar("Couldn't sync restart — will retry.")
133+
val msg = when (event) {
134+
LibraryEvent.RestartFailed -> "Couldn't sync restart — will retry."
135+
is LibraryEvent.RestoreFinished -> {
136+
val s = event.summary
137+
"Restored ${s.downloaded} of ${s.requested}" +
138+
if (s.failed > 0) " · ${s.failed} failed" else ""
139+
}
140+
is LibraryEvent.RestoreFailed -> "Restore failed: ${event.message}"
131141
}
142+
snackbarHostState.showSnackbar(msg)
132143
}
133144
}
134145

@@ -162,10 +173,19 @@ fun LibraryScreen(
162173
}
163174

164175
if (items.isEmpty() && !searchActive && query.isBlank()) {
165-
EmptyState(
166-
modifier = Modifier.padding(contentPadding),
167-
onImport = launchPicker,
168-
)
176+
Box(modifier = Modifier.fillMaxSize()) {
177+
EmptyState(
178+
modifier = Modifier.padding(contentPadding),
179+
onImport = launchPicker,
180+
canRestore = canRestore,
181+
restoreRunning = restoreRunning,
182+
onRestore = { viewModel.restoreInProgressBooks() },
183+
)
184+
SnackbarHost(
185+
hostState = snackbarHostState,
186+
modifier = Modifier.align(Alignment.BottomCenter),
187+
)
188+
}
169189
return
170190
}
171191

@@ -536,7 +556,13 @@ fun LibraryScreen(
536556
}
537557

538558
@Composable
539-
private fun EmptyState(modifier: Modifier = Modifier, onImport: (() -> Unit)? = null) {
559+
private fun EmptyState(
560+
modifier: Modifier = Modifier,
561+
onImport: (() -> Unit)? = null,
562+
canRestore: Boolean = false,
563+
restoreRunning: Boolean = false,
564+
onRestore: () -> Unit = {},
565+
) {
540566
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
541567
Column(horizontalAlignment = Alignment.CenterHorizontally) {
542568
Text(
@@ -569,6 +595,22 @@ private fun EmptyState(modifier: Modifier = Modifier, onImport: (() -> Unit)? =
569595
Text(text = "Import EPUB", modifier = Modifier.padding(start = 8.dp))
570596
}
571597
}
598+
// Task 6: empty-state restore prompt — only when an account is
599+
// connected and the library is empty (reconnected on a new device).
600+
if (canRestore) {
601+
Spacer(Modifier.height(16.dp))
602+
Text(
603+
"Reconnected on a new device? Bring back the books you were reading.",
604+
style = MaterialTheme.typography.bodyMedium,
605+
textAlign = TextAlign.Center,
606+
color = MaterialTheme.colorScheme.onSurfaceVariant,
607+
)
608+
Spacer(Modifier.height(8.dp))
609+
Button(
610+
onClick = onRestore,
611+
enabled = !restoreRunning,
612+
) { Text(if (restoreRunning) "Restoring…" else "Restore in-progress books") }
613+
}
572614
}
573615
}
574616
}

app/src/main/java/io/theficos/ereader/ui/library/LibraryViewModel.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import java.io.File
2626

2727
sealed interface LibraryEvent {
2828
data object RestartFailed : LibraryEvent
29+
data class RestoreFinished(val summary: io.theficos.ereader.domain.restore.RestoreSummary) : LibraryEvent
30+
data class RestoreFailed(val message: String) : LibraryEvent
2931
}
3032

3133
@OptIn(ExperimentalCoroutinesApi::class)
@@ -37,6 +39,8 @@ class LibraryViewModel(
3739
private val libraryPreferencesStore: LibraryPreferencesStore,
3840
private val nowMillis: () -> Long = System::currentTimeMillis,
3941
private val syncEnqueuer: (Context) -> Unit = { SyncEnqueuer.enqueue(it, expedited = true, replaceExisting = true) },
42+
private val credentialStore: io.theficos.ereader.auth.CalibreCredentialStore? = null,
43+
private val restoreInProgress: (suspend () -> io.theficos.ereader.domain.restore.RestoreSummary)? = null,
4044
) : ViewModel() {
4145

4246
val sort: StateFlow<LibrarySort> = libraryPreferencesStore.flow
@@ -119,6 +123,37 @@ class LibraryViewModel(
119123
private val _events = MutableSharedFlow<LibraryEvent>(extraBufferCapacity = 4)
120124
val events: SharedFlow<LibraryEvent> = _events.asSharedFlow()
121125

126+
private val connected: StateFlow<Boolean> =
127+
(credentialStore?.accountFlow?.map { it != null } ?: flowOf(false))
128+
.stateIn(viewModelScope, SharingStarted.Eagerly, credentialStore?.accountFlow?.value != null)
129+
130+
/**
131+
* True only when the library is empty AND an account is connected — the
132+
* one situation where the empty-state "Restore in-progress books" prompt
133+
* is worth surfacing (reconnected on a new/wiped device).
134+
*/
135+
val canRestore: StateFlow<Boolean> =
136+
combine(rows, connected) { r, conn -> conn && r.isEmpty() }
137+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false)
138+
139+
private val _restoreRunning = kotlinx.coroutines.flow.MutableStateFlow(false)
140+
val restoreRunning: StateFlow<Boolean> = _restoreRunning.asStateFlow()
141+
142+
fun restoreInProgressBooks() {
143+
val restore = restoreInProgress ?: return
144+
if (_restoreRunning.value) return
145+
_restoreRunning.value = true
146+
viewModelScope.launch {
147+
try {
148+
_events.tryEmit(LibraryEvent.RestoreFinished(restore()))
149+
} catch (t: Throwable) {
150+
_events.tryEmit(LibraryEvent.RestoreFailed(t.message ?: t.javaClass.simpleName))
151+
} finally {
152+
_restoreRunning.value = false
153+
}
154+
}
155+
}
156+
122157
fun delete(document: Document) {
123158
viewModelScope.launch { docs.delete(document) }
124159
}

0 commit comments

Comments
 (0)