Skip to content

Commit f7588be

Browse files
committed
feat(restore): run downloads off main thread and surface per-book progress
1 parent cf5b65c commit f7588be

8 files changed

Lines changed: 113 additions & 39 deletions

File tree

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

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -292,35 +292,41 @@ class AppContainer(context: Context) {
292292
},
293293
isPresent = { identity -> documentRepository.findByIdentity(identity) != null },
294294
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) {
295+
// Identity hashing (whole-EPUB hash) and OPF zip parsing are plain
296+
// CPU/IO with no internal dispatcher; the use case runs on Main
297+
// (viewModelScope). Move all per-book work off the main thread to
298+
// avoid an ANR while restoring.
299+
withContext(Dispatchers.IO) {
300+
val fileName = "${java.util.UUID.randomUUID()}.epub"
301+
val file = bookDownloader.download(c.opdsHref, fileName) { _, _ -> }
302+
val coverFile: java.io.File? = null
303+
// If identity extraction / OPF read / insert throws after the bytes
304+
// landed, delete the temp file before rethrowing so a re-run (this
305+
// feature is explicitly re-runnable) doesn't accumulate orphans.
306+
try {
307+
val identity = extractIdentity(file)
308+
if (documentRepository.findByIdentity(identity) != null) {
309+
file.delete()
310+
coverFile?.delete()
311+
} else {
312+
val opf = readOpfBundle(file, fallbackTitle = c.title)
313+
documentRepository.insert(
314+
identity = identity,
315+
title = c.title,
316+
author = c.authors.firstOrNull(),
317+
downloadUrl = c.opdsHref,
318+
localPath = file.absolutePath,
319+
coverPath = coverFile?.absolutePath,
320+
downloadedAt = System.currentTimeMillis(),
321+
seriesName = opf.seriesName,
322+
seriesIndex = opf.seriesPosition?.toDouble(),
323+
)
324+
}
325+
} catch (t: Throwable) {
304326
file.delete()
305327
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-
)
328+
throw t
319329
}
320-
} catch (t: Throwable) {
321-
file.delete()
322-
coverFile?.delete()
323-
throw t
324330
}
325331
},
326332
applyPositions = { items -> syncOrchestrator.applyProgressItems(items) },

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ fun AppNavGraph(container: AppContainer) {
112112
booksDir = container.booksDir,
113113
libraryPreferencesStore = container.libraryPreferencesStore,
114114
credentialStore = container.credentialStore,
115-
restoreInProgress = { container.restoreInProgressUseCase().run() },
115+
restoreInProgress = { onProgress -> container.restoreInProgressUseCase().run(onProgress) },
116116
)
117117
}
118118
val catVm = remember {
@@ -142,7 +142,7 @@ fun AppNavGraph(container: AppContainer) {
142142
aiRepository = container.aiRepository,
143143
insightSyncRepository = container.insightSyncRepository,
144144
insightDao = container.insightDao,
145-
restoreInProgress = { container.restoreInProgressUseCase().run() },
145+
restoreInProgress = { onProgress -> container.restoreInProgressUseCase().run(onProgress) },
146146
)
147147
}
148148
val aiConfig by container.aiRepository.config.collectAsState()

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

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ fun LibraryScreen(
127127
val query by viewModel.query.collectAsState()
128128
val canRestore by viewModel.canRestore.collectAsState()
129129
val restoreRunning by viewModel.restoreRunning.collectAsState()
130+
val restoreProgress by viewModel.restoreProgress.collectAsState()
130131

131132
LaunchedEffect(Unit) {
132133
viewModel.events.collect { event ->
@@ -179,6 +180,7 @@ fun LibraryScreen(
179180
onImport = launchPicker,
180181
canRestore = canRestore,
181182
restoreRunning = restoreRunning,
183+
restoreProgress = restoreProgress,
182184
onRestore = { viewModel.restoreInProgressBooks() },
183185
)
184186
SnackbarHost(
@@ -561,6 +563,7 @@ private fun EmptyState(
561563
onImport: (() -> Unit)? = null,
562564
canRestore: Boolean = false,
563565
restoreRunning: Boolean = false,
566+
restoreProgress: io.theficos.ereader.domain.restore.RestoreProgress? = null,
564567
onRestore: () -> Unit = {},
565568
) {
566569
Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -606,10 +609,16 @@ private fun EmptyState(
606609
color = MaterialTheme.colorScheme.onSurfaceVariant,
607610
)
608611
Spacer(Modifier.height(8.dp))
612+
val restoreLabel = when {
613+
restoreRunning && restoreProgress != null ->
614+
"Restoring… (${restoreProgress.done}/${restoreProgress.total})"
615+
restoreRunning -> "Restoring…"
616+
else -> "Restore in-progress books"
617+
}
609618
Button(
610619
onClick = onRestore,
611620
enabled = !restoreRunning,
612-
) { Text(if (restoreRunning) "Restoring…" else "Restore in-progress books") }
621+
) { Text(restoreLabel) }
613622
}
614623
}
615624
}

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class LibraryViewModel(
4040
private val nowMillis: () -> Long = System::currentTimeMillis,
4141
private val syncEnqueuer: (Context) -> Unit = { SyncEnqueuer.enqueue(it, expedited = true, replaceExisting = true) },
4242
private val credentialStore: io.theficos.ereader.auth.CalibreCredentialStore? = null,
43-
private val restoreInProgress: (suspend () -> io.theficos.ereader.domain.restore.RestoreSummary)? = null,
43+
private val restoreInProgress: (suspend ((Int, Int) -> Unit) -> io.theficos.ereader.domain.restore.RestoreSummary)? = null,
4444
) : ViewModel() {
4545

4646
val sort: StateFlow<LibrarySort> = libraryPreferencesStore.flow
@@ -139,17 +139,30 @@ class LibraryViewModel(
139139
private val _restoreRunning = kotlinx.coroutines.flow.MutableStateFlow(false)
140140
val restoreRunning: StateFlow<Boolean> = _restoreRunning.asStateFlow()
141141

142+
private val _restoreProgress =
143+
kotlinx.coroutines.flow.MutableStateFlow<io.theficos.ereader.domain.restore.RestoreProgress?>(null)
144+
val restoreProgress: StateFlow<io.theficos.ereader.domain.restore.RestoreProgress?> =
145+
_restoreProgress.asStateFlow()
146+
142147
fun restoreInProgressBooks() {
143148
val restore = restoreInProgress ?: return
144149
if (_restoreRunning.value) return
145150
_restoreRunning.value = true
146151
viewModelScope.launch {
147152
try {
148-
_events.tryEmit(LibraryEvent.RestoreFinished(restore()))
153+
_events.tryEmit(
154+
LibraryEvent.RestoreFinished(
155+
restore { done, total ->
156+
_restoreProgress.value =
157+
io.theficos.ereader.domain.restore.RestoreProgress(done, total)
158+
},
159+
),
160+
)
149161
} catch (t: Throwable) {
150162
_events.tryEmit(LibraryEvent.RestoreFailed(t.message ?: t.javaClass.simpleName))
151163
} finally {
152164
_restoreRunning.value = false
165+
_restoreProgress.value = null
153166
}
154167
}
155168
}

app/src/main/java/io/theficos/ereader/ui/settings/SettingsScreen.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ fun SettingsScreen(
7676
val deleteInFlight by viewModel.deleteProfileInFlight.collectAsState()
7777
val isConnected by viewModel.isConnected.collectAsState()
7878
val restoreRunning by viewModel.restoreRunning.collectAsState()
79+
val restoreProgress by viewModel.restoreProgress.collectAsState()
7980
val snackbarHostState = remember { SnackbarHostState() }
8081

8182
LaunchedEffect(Unit) {
@@ -262,10 +263,16 @@ fun SettingsScreen(
262263
style = MaterialTheme.typography.bodyMedium,
263264
color = MaterialTheme.colorScheme.onSurfaceVariant,
264265
)
266+
val restoreLabel = when {
267+
restoreRunning && restoreProgress != null ->
268+
"Restoring… (${restoreProgress!!.done}/${restoreProgress!!.total})"
269+
restoreRunning -> "Restoring…"
270+
else -> "Restore in-progress books"
271+
}
265272
TextButton(
266273
onClick = { viewModel.restoreInProgressBooks() },
267274
enabled = !restoreRunning,
268-
) { Text(if (restoreRunning) "Restoring…" else "Restore in-progress books") }
275+
) { Text(restoreLabel) }
269276
}
270277
}
271278
}

app/src/main/java/io/theficos/ereader/ui/settings/SettingsViewModel.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import io.theficos.ereader.data.local.DocumentRepository
1313
import io.theficos.ereader.data.local.db.InsightDao
1414
import io.theficos.ereader.data.local.db.SyncStateDao
1515
import io.theficos.ereader.data.sync.SyncEnqueuer
16+
import io.theficos.ereader.domain.restore.RestoreProgress
1617
import io.theficos.ereader.domain.restore.RestoreSummary
1718
import java.io.File
1819
import io.theficos.ereader.reader.ReaderFontFamily
@@ -74,7 +75,7 @@ class SettingsViewModel(
7475
private val insightSyncRepository: InsightSyncRepository? = null,
7576
private val insightDao: InsightDao? = null,
7677
private val syncEnqueuer: (Context) -> Unit = { SyncEnqueuer.enqueue(it, expedited = true, replaceExisting = true) },
77-
private val restoreInProgress: (suspend () -> RestoreSummary)? = null,
78+
private val restoreInProgress: (suspend ((Int, Int) -> Unit) -> RestoreSummary)? = null,
7879
) : ViewModel() {
7980
private val _calibre = MutableStateFlow(loadInitialCalibre())
8081
val calibre: StateFlow<CalibreUiState> = _calibre.asStateFlow()
@@ -103,6 +104,9 @@ class SettingsViewModel(
103104
private val _restoreRunning = MutableStateFlow(false)
104105
val restoreRunning: StateFlow<Boolean> = _restoreRunning.asStateFlow()
105106

107+
private val _restoreProgress = MutableStateFlow<RestoreProgress?>(null)
108+
val restoreProgress: StateFlow<RestoreProgress?> = _restoreProgress.asStateFlow()
109+
106110
val ai: StateFlow<AiState> = combine(
107111
combine(aiRepository.config, aiRepository.preferences, _aiHealth) { c, p, h ->
108112
Triple(c, p, h)
@@ -272,11 +276,16 @@ class SettingsViewModel(
272276
_restoreRunning.value = true
273277
viewModelScope.launch {
274278
try {
275-
_events.tryEmit(SettingsEvent.RestoreFinished(restore()))
279+
_events.tryEmit(
280+
SettingsEvent.RestoreFinished(
281+
restore { done, total -> _restoreProgress.value = RestoreProgress(done, total) },
282+
),
283+
)
276284
} catch (t: Throwable) {
277285
_events.tryEmit(SettingsEvent.RestoreFailed(t.message ?: t.javaClass.simpleName))
278286
} finally {
279287
_restoreRunning.value = false
288+
_restoreProgress.value = null
280289
}
281290
}
282291
}

app/src/test/java/io/theficos/ereader/ui/library/LibraryViewModelTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ class LibraryViewModelTest {
295295
libraryPreferencesStore = LibraryPreferencesStore(ApplicationProvider.getApplicationContext()),
296296
nowMillis = { 999L },
297297
credentialStore = store,
298-
restoreInProgress = { RestoreSummary(0, 0, 0, 0, 0) },
298+
restoreInProgress = { _ -> RestoreSummary(0, 0, 0, 0, 0) },
299299
)
300300

301301
@Test fun `canRestore is true when connected and library empty`() = runTest {

app/src/test/java/io/theficos/ereader/ui/settings/SettingsViewModelTest.kt

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class SettingsViewModelTest {
6565
Dispatchers.resetMain()
6666
}
6767

68-
private fun buildVm(restoreInProgress: (suspend () -> RestoreSummary)?): SettingsViewModel =
68+
private fun buildVm(restoreInProgress: (suspend ((Int, Int) -> Unit) -> RestoreSummary)?): SettingsViewModel =
6969
SettingsViewModel(
7070
store = store,
7171
readerStore = ReaderPreferencesStore(context),
@@ -87,7 +87,7 @@ class SettingsViewModelTest {
8787
skippedUnfetchable = 0,
8888
failed = 0,
8989
)
90-
val vm = buildVm(restoreInProgress = { summary })
90+
val vm = buildVm(restoreInProgress = { _ -> summary })
9191

9292
val events = mutableListOf<SettingsEvent>()
9393
val job = launch { vm.events.collect { events += it } }
@@ -101,9 +101,39 @@ class SettingsViewModelTest {
101101
job.cancel()
102102
}
103103

104+
@Test fun `restoreInProgressBooks surfaces per-book progress and clears it on completion`() = runTest {
105+
store.saveBasicAccount(baseUrl = "https://books.example.com", username = "alice", password = "pw")
106+
val summary = RestoreSummary(
107+
requested = 2,
108+
downloaded = 2,
109+
skippedExisting = 0,
110+
skippedUnfetchable = 0,
111+
failed = 0,
112+
)
113+
val vm = buildVm(
114+
restoreInProgress = { onProgress ->
115+
onProgress(1, 2)
116+
onProgress(2, 2)
117+
summary
118+
},
119+
)
120+
121+
val events = mutableListOf<SettingsEvent>()
122+
val job = launch { vm.events.collect { events += it } }
123+
124+
vm.restoreInProgressBooks()
125+
advanceUntilIdle()
126+
127+
assertThat(events).contains(SettingsEvent.RestoreFinished(summary))
128+
// finally clears progress after the run completes.
129+
assertThat(vm.restoreProgress.value).isNull()
130+
assertThat(vm.restoreRunning.value).isFalse()
131+
job.cancel()
132+
}
133+
104134
@Test fun `restoreInProgressBooks emits RestoreFailed when the use case throws`() = runTest {
105135
store.saveBasicAccount(baseUrl = "https://books.example.com", username = "alice", password = "pw")
106-
val vm = buildVm(restoreInProgress = { throw IllegalStateException("boom") })
136+
val vm = buildVm(restoreInProgress = { _ -> throw IllegalStateException("boom") })
107137

108138
val events = mutableListOf<SettingsEvent>()
109139
val job = launch { vm.events.collect { events += it } }
@@ -117,7 +147,7 @@ class SettingsViewModelTest {
117147
}
118148

119149
@Test fun `isConnected is false when no account configured`() = runTest {
120-
val vm = buildVm(restoreInProgress = { error("should not run") })
150+
val vm = buildVm(restoreInProgress = { _ -> error("should not run") })
121151
advanceUntilIdle()
122152
assertThat(vm.isConnected.value).isFalse()
123153
}

0 commit comments

Comments
 (0)