Skip to content

Commit 5f4e041

Browse files
committed
fix(session): Fix initial scan hang on empty media device and improve empty state UX
Resolves a critical bug where the application would hang indefinitely on the "Scanning device..." screen during a fresh installation on devices with no media files. Bumps app version to 0.9.2. The root cause was a race condition in the data loading pipeline. The previous implementation could not distinguish between a temporary empty state from the Room cache and a definitive "no folders found" result after a full scan. This commit refactors the folder loading mechanism in `DirectMediaRepositoryImpl` to use the `transformLatest` operator, which guarantees a single, definitive emission for the initial load. Additionally, this commit introduces a new, user-friendly empty state message on the `SessionSetupScreen`. - A dedicated message with an icon now appears when no media folders are found, replacing the previous blank screen for better UX. - The implementation ensures that pull-to-refresh functionality remains fully intact on this new empty state screen. - A flicker guard was also added to the `SessionSetupViewModel` to prevent the folder list from disappearing during a manual refresh. This commit also improves some text for clarity in the "Mark as Sorted" confirmation dialog and in the Swiper "All media has been organized" page. VERSION: 0.9.1 -> 0.9.2
1 parent 163148e commit 5f4e041

File tree

5 files changed

+146
-82
lines changed

5 files changed

+146
-82
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ android {
3939
minSdk = 29
4040
targetSdk = 36
4141
versionCode = 1
42-
versionName = "0.9.1"
42+
versionName = "0.9.2"
4343

4444
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
4545
vectorDrawables {

app/src/main/java/com/cleansweep/data/repository/DirectMediaRepositoryImpl.kt

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ class DirectMediaRepositoryImpl @Inject constructor(
5858

5959
private var folderDetailsCache: List<FolderDetails>? = null
6060
private var lastFileDiscoveryCache: List<File>? = null
61-
private val isFullScanInitiated = AtomicBoolean(false)
6261

6362
@Volatile
6463
private var lastKnownFolderState: Set<String>? = null
@@ -228,7 +227,6 @@ class DirectMediaRepositoryImpl @Inject constructor(
228227
private fun invalidateCaches() {
229228
folderDetailsCache = null
230229
lastFileDiscoveryCache = null // Invalidate file discovery cache
231-
isFullScanInitiated.set(false)
232230
externalScope.launch {
233231
folderDetailsDao.clear()
234232
}
@@ -911,17 +909,33 @@ class DirectMediaRepositoryImpl @Inject constructor(
911909

912910
override fun observeMediaFoldersWithDetails(): Flow<List<FolderDetails>> =
913911
folderDetailsDao.getAll()
914-
.map { cachedList ->
915-
if (cachedList.isEmpty() && isFullScanInitiated.compareAndSet(false, true)) {
916-
Log.d("CacheDebug", "Observe: DB is empty, triggering file system scan.")
912+
.transformLatest { cachedList ->
913+
// This atomic is a guard to ensure the expensive file scan only ever runs once
914+
// per app session, even if multiple collectors subscribe.
915+
val isScanNeeded = AtomicBoolean(cachedList.isEmpty())
916+
917+
if (isScanNeeded.get()) {
918+
Log.d("CacheDebug", "transformLatest: DB is empty, triggering scan.")
917919
val (details, _) = scanFileSystemForFolderDetails()
918-
folderDetailsDao.upsertAll(details.map { it.toFolderDetailsCache() })
919-
details
920+
921+
if (details.isEmpty()) {
922+
// Scan found nothing. This is the definitive empty state for a fresh device.
923+
// Emit it so the UI can stop loading.
924+
Log.d("CacheDebug", "transformLatest: Scan found no folders. Emitting definitive empty list.")
925+
emit(emptyList())
926+
} else {
927+
// Scan found folders. Upsert them into the database.
928+
// The `transformLatest` operator will see the new DB emission, cancel this
929+
// current block, and re-run with the `cachedList` populated, hitting the `else`
930+
// block below. We do not emit here to prevent duplicate data.
931+
Log.d("CacheDebug", "transformLatest: Scan found ${details.size} folders. Upserting to DB.")
932+
folderDetailsDao.upsertAll(details.map { it.toFolderDetailsCache() })
933+
}
920934
} else {
921-
Log.d("CacheDebug", "Observe: Emitting ${cachedList.size} folders from DB.")
922-
cachedList
923-
.map { it.toFolderDetails() }
924-
.filter { it.itemCount > 0 } // Prevent temporary 0-count folders from showing
935+
// The DB has data. Transform it and emit. This is the normal path for updates.
936+
Log.d("CacheDebug", "transformLatest: Emitting ${cachedList.size} folders from DB.")
937+
val transformedList = cachedList.map { it.toFolderDetails() }.filter { it.itemCount > 0 }
938+
emit(transformedList)
925939
}
926940
}
927941
.flowOn(Dispatchers.IO)
@@ -1220,6 +1234,7 @@ class DirectMediaRepositoryImpl @Inject constructor(
12201234
)
12211235

12221236
override fun observeAllFolders(): Flow<List<Pair<String, String>>> {
1237+
val isFullScanInitiated = AtomicBoolean(false)
12231238
if (isFullScanInitiated.compareAndSet(false, true)) {
12241239
externalScope.launch {
12251240
Log.d("CacheDebug", "Initiating full background folder scan.")

app/src/main/java/com/cleansweep/ui/screens/session/SessionSetupScreen.kt

Lines changed: 102 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import androidx.compose.ui.platform.LocalContext
3535
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
3636
import androidx.compose.ui.text.font.FontWeight
3737
import androidx.compose.ui.text.input.ImeAction
38+
import androidx.compose.ui.text.style.TextAlign
3839
import androidx.compose.ui.text.style.TextOverflow
3940
import androidx.compose.ui.unit.DpOffset
4041
import androidx.compose.ui.unit.dp
@@ -128,9 +129,9 @@ fun SessionSetupScreen(
128129
val isRecursive = singleFolder.bucketId in uiState.recursivelySelectedRoots
129130
titleText = "Mark as Sorted?"
130131
bodyText = if (isRecursive) {
131-
"Are you sure you want to permanently hide '${singleFolder.bucketName}' and its subfolders from this list? You can reset this in the settings."
132+
"Are you sure you want to permanently hide '${singleFolder.bucketName}' and its subfolders from this list? These folders won't show up here even if you add new media to them. You can reset this in the settings."
132133
} else {
133-
"Are you sure you want to permanently hide '${singleFolder.bucketName}' from this list? You can reset this in the settings."
134+
"Are you sure you want to permanently hide '${singleFolder.bucketName}' from this list? This folder won't show up here even if you add new media to it. You can reset this in the settings."
134135
}
135136
} else {
136137
titleText = "Mark ${foldersToMark.size} Folders as Sorted?"
@@ -309,74 +310,118 @@ fun SessionSetupScreen(
309310
onRefresh = viewModel::refreshFolders,
310311
modifier = Modifier.fillMaxSize()
311312
) {
312-
val listState = rememberLazyListState()
313-
Box(modifier = Modifier.fillMaxSize()) {
314-
LazyColumn(
315-
state = listState,
316-
modifier = Modifier.fillMaxSize(),
317-
contentPadding = PaddingValues(
318-
start = 16.dp,
319-
end = 16.dp,
320-
top = 8.dp,
321-
bottom = 96.dp
322-
),
323-
verticalArrangement = Arrangement.spacedBy(4.dp)
324-
) {
325-
uiState.folderCategories.forEach { category ->
326-
if (category.folders.isNotEmpty()) {
327-
item {
328-
Text(
329-
text = category.name,
330-
style = MaterialTheme.typography.titleMedium,
331-
color = MaterialTheme.colorScheme.primary,
332-
modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
333-
)
334-
}
335-
items(category.folders, key = { it.bucketId }) { folderInfo ->
336-
val isSelectedForSession = folderInfo.bucketId in uiState.selectedBuckets
337-
val isSelectedForContext = folderInfo.bucketId in uiState.contextSelectedFolderPaths
338-
EnhancedFolderItem(
339-
folderInfo = folderInfo,
340-
isSelected = if (uiState.isContextualSelectionMode) isSelectedForContext else isSelectedForSession,
341-
isContextualMode = uiState.isContextualSelectionMode,
342-
isFavorite = folderInfo.bucketId in uiState.favoriteFolders,
343-
isRecursiveRoot = folderInfo.bucketId in uiState.recursivelySelectedRoots,
344-
onToggle = {
345-
if (uiState.isContextualSelectionMode) {
346-
viewModel.toggleContextualSelection(folderInfo.bucketId)
347-
} else {
348-
if (isSelectedForSession) {
349-
viewModel.unselectBucket(folderInfo.bucketId)
313+
if (uiState.folderCategories.isEmpty()) {
314+
// WRAP the empty state in a LazyColumn to make it scrollable,
315+
// which is required for PullToRefreshBox to work correctly.
316+
LazyColumn(modifier = Modifier.fillMaxSize()) {
317+
item {
318+
EmptyStateMessage(modifier = Modifier.fillParentMaxSize())
319+
}
320+
}
321+
} else {
322+
val listState = rememberLazyListState()
323+
Box(modifier = Modifier.fillMaxSize()) {
324+
LazyColumn(
325+
state = listState,
326+
modifier = Modifier.fillMaxSize(),
327+
contentPadding = PaddingValues(
328+
start = 16.dp,
329+
end = 16.dp,
330+
top = 8.dp,
331+
bottom = 96.dp
332+
),
333+
verticalArrangement = Arrangement.spacedBy(4.dp)
334+
) {
335+
uiState.folderCategories.forEach { category ->
336+
if (category.folders.isNotEmpty()) {
337+
item {
338+
Text(
339+
text = category.name,
340+
style = MaterialTheme.typography.titleMedium,
341+
color = MaterialTheme.colorScheme.primary,
342+
modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
343+
)
344+
}
345+
items(category.folders, key = { it.bucketId }) { folderInfo ->
346+
val isSelectedForSession = folderInfo.bucketId in uiState.selectedBuckets
347+
val isSelectedForContext = folderInfo.bucketId in uiState.contextSelectedFolderPaths
348+
EnhancedFolderItem(
349+
folderInfo = folderInfo,
350+
isSelected = if (uiState.isContextualSelectionMode) isSelectedForContext else isSelectedForSession,
351+
isContextualMode = uiState.isContextualSelectionMode,
352+
isFavorite = folderInfo.bucketId in uiState.favoriteFolders,
353+
isRecursiveRoot = folderInfo.bucketId in uiState.recursivelySelectedRoots,
354+
onToggle = {
355+
if (uiState.isContextualSelectionMode) {
356+
viewModel.toggleContextualSelection(folderInfo.bucketId)
350357
} else {
351-
viewModel.selectBucket(folderInfo.bucketId)
358+
if (isSelectedForSession) {
359+
viewModel.unselectBucket(folderInfo.bucketId)
360+
} else {
361+
viewModel.selectBucket(folderInfo.bucketId)
362+
}
352363
}
353-
}
354-
},
355-
onLongPress = {
356-
viewModel.enterContextualSelectionMode(folderInfo.bucketId)
357-
},
358-
onToggleFavorite = { viewModel.toggleFavorite(folderInfo.bucketId) },
359-
onSelectRecursively = { viewModel.selectFolderRecursively(folderInfo.bucketId) },
360-
onDeselectRecursively = { viewModel.deselectChildren(folderInfo.bucketId) },
361-
onRename = { viewModel.showRenameDialog(folderInfo.bucketId) },
362-
onMove = { viewModel.showMoveFolderDialog(folderInfo.bucketId) },
363-
onMarkAsSorted = { viewModel.markFolderAsSorted(folderInfo) }
364-
)
364+
},
365+
onLongPress = {
366+
viewModel.enterContextualSelectionMode(folderInfo.bucketId)
367+
},
368+
onToggleFavorite = { viewModel.toggleFavorite(folderInfo.bucketId) },
369+
onSelectRecursively = { viewModel.selectFolderRecursively(folderInfo.bucketId) },
370+
onDeselectRecursively = { viewModel.deselectChildren(folderInfo.bucketId) },
371+
onRename = { viewModel.showRenameDialog(folderInfo.bucketId) },
372+
onMove = { viewModel.showMoveFolderDialog(folderInfo.bucketId) },
373+
onMarkAsSorted = { viewModel.markFolderAsSorted(folderInfo) }
374+
)
375+
}
365376
}
366377
}
367378
}
379+
FastScrollbar(
380+
state = listState,
381+
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 4.dp)
382+
)
368383
}
369-
FastScrollbar(
370-
state = listState,
371-
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 4.dp)
372-
)
373384
}
374385
}
375386
}
376387
}
377388
}
378389
}
379390

391+
@Composable
392+
private fun EmptyStateMessage(modifier: Modifier = Modifier) {
393+
Box(
394+
modifier = modifier
395+
.padding(16.dp),
396+
contentAlignment = Alignment.Center
397+
) {
398+
Column(
399+
horizontalAlignment = Alignment.CenterHorizontally,
400+
verticalArrangement = Arrangement.spacedBy(16.dp),
401+
modifier = Modifier.padding(bottom = 64.dp) // Offset from FABs
402+
) {
403+
Icon(
404+
imageVector = Icons.Default.PhotoLibrary,
405+
contentDescription = null,
406+
modifier = Modifier.size(64.dp),
407+
tint = MaterialTheme.colorScheme.surfaceVariant
408+
)
409+
Text(
410+
text = "No media folders found",
411+
style = MaterialTheme.typography.titleLarge
412+
)
413+
Text(
414+
text = "This can happen on a new device. Try adding some photos or videos, or pull down to re-scan.",
415+
style = MaterialTheme.typography.bodyMedium,
416+
color = MaterialTheme.colorScheme.onSurfaceVariant,
417+
textAlign = TextAlign.Center,
418+
modifier = Modifier.padding(horizontal = 16.dp)
419+
)
420+
}
421+
}
422+
}
423+
424+
380425
@OptIn(ExperimentalMaterial3Api::class)
381426
@Composable
382427
private fun DefaultTopAppBar(

0 commit comments

Comments
 (0)