Skip to content

Commit 3ba9e6b

Browse files
committed
ref(explorer): Extract shared LocationLoaderContext to align loader architecture
Consolidates duplicated state management logic from Device and Directory loaders into a generic LocationLoaderContext class. Aligns all three loaders (Home, Device, Directory) with a consistent multi-stage loading pattern while adding missing cancellation checks.
1 parent f2fa184 commit 3ba9e6b

8 files changed

Lines changed: 123 additions & 98 deletions

File tree

app-workspace-explorer/src/main/java/eu/darken/butler/explorer/core/engine/DeviceLocationLoader.kt

Lines changed: 11 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -38,36 +38,6 @@ class DeviceLocationLoader @Inject constructor(
3838

3939
private val tag = logTag("Explorer", "DeviceLocationLoader")
4040

41-
private class LoaderContext(
42-
private val permissionState: PermissionState,
43-
private val emit: suspend (ExplorerLocation.Device) -> Unit,
44-
) {
45-
private var currentState = ExplorerLocation.Device(
46-
permissionState = permissionState,
47-
progress = Progress.Data(
48-
primary = R.string.explorer_loader_progress_device_loading.toCaString(),
49-
),
50-
)
51-
val state: ExplorerLocation.Device get() = currentState
52-
53-
suspend fun updateState(transform: ExplorerLocation.Device.() -> ExplorerLocation.Device) {
54-
currentState = currentState.transform()
55-
emit(currentState)
56-
}
57-
58-
suspend fun updateProgressMsg(@StringRes msg: Int) = updateState {
59-
copy(
60-
progress = currentState.progress!!.copy(
61-
secondary = msg.toCaString(),
62-
),
63-
)
64-
}
65-
66-
suspend fun emitState() {
67-
emit(currentState)
68-
}
69-
}
70-
7141
private suspend fun checkLocationPermissions(): PermissionState {
7242
log(tag) { "checkLocationPermissions(): Checking permissions for Device" }
7343

@@ -82,7 +52,15 @@ class DeviceLocationLoader @Inject constructor(
8252
log(tag, INFO) { "loadDevice(): Loading device location with multi-stage loading" }
8353

8454
val permissionState = checkLocationPermissions()
85-
val context = LoaderContext(permissionState, ::emit)
55+
val context = LocationLoaderContext(
56+
initialState = ExplorerLocation.Device(
57+
permissionState = permissionState,
58+
progress = Progress.Data(
59+
primary = R.string.explorer_loader_progress_device_loading.toCaString(),
60+
),
61+
),
62+
emit = ::emit
63+
)
8664
context.emitState()
8765

8866
gatewaySwitch.useRes {
@@ -96,7 +74,7 @@ class DeviceLocationLoader @Inject constructor(
9674
log(tag, INFO) { "loadDevice(): Stage 2 complete with filesystem info" }
9775
}
9876

99-
private suspend fun LoaderContext.loadQuickList() {
77+
private suspend fun LocationLoaderContext<ExplorerLocation.Device>.loadQuickList() {
10078
log(tag) { "loadQuickList(): Loading storage list without filesystem info" }
10179
updateProgressMsg(R.string.explorer_loader_progress_device_locations)
10280

@@ -178,7 +156,7 @@ class DeviceLocationLoader @Inject constructor(
178156
null
179157
}
180158

181-
private suspend fun LoaderContext.loadFilesystemInfo() {
159+
private suspend fun LocationLoaderContext<ExplorerLocation.Device>.loadFilesystemInfo() {
182160
log(tag) { "loadFilesystemInfo(): Loading filesystem info sequentially with incremental updates" }
183161

184162
val currentItems = state.items ?: return

app-workspace-explorer/src/main/java/eu/darken/butler/explorer/core/engine/DirectoryLocationLoader.kt

Lines changed: 26 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import eu.darken.butler.common.storage.StorageEnvironment
2424
import eu.darken.butler.explorer.R
2525
import eu.darken.butler.workspace.core.Workspace
2626
import eu.darken.butler.workspace.core.permissions.PathPermissionCheck
27+
import kotlin.coroutines.coroutineContext
28+
import kotlinx.coroutines.ensureActive
2729
import kotlinx.coroutines.flow.Flow
2830
import kotlinx.coroutines.flow.flatMapLatest
2931
import kotlinx.coroutines.flow.flow
@@ -38,50 +40,28 @@ class DirectoryLocationLoader @AssistedInject constructor(
3840

3941
private val tag = logTag("Explorer", "Workspace", workspaceId.shortTag, "DirectoryLoader")
4042

41-
private class LoaderContext(
42-
private val path: APath<*>,
43-
private val permissionState: eu.darken.butler.workspace.core.permissions.PermissionState,
44-
private val emit: suspend (ExplorerLocation.Directory) -> Unit,
45-
) {
46-
private var currentState = ExplorerLocation.Directory(
47-
path = path,
48-
permissionState = permissionState,
49-
progress = Progress.Data(
50-
primary = caString {
51-
it.getString(
52-
R.string.explorer_loader_progress_directory_loading,
53-
path.userReadablePath.get(it)
54-
)
55-
},
56-
)
57-
)
58-
val state: ExplorerLocation.Directory get() = currentState
59-
60-
suspend fun updateState(transform: ExplorerLocation.Directory.() -> ExplorerLocation.Directory) {
61-
currentState = currentState.transform()
62-
emit(currentState)
63-
}
64-
65-
suspend fun updateProgressMsg(@StringRes msg: Int) = updateState {
66-
copy(
67-
progress = currentState.progress?.copy(
68-
secondary = msg.toCaString()
69-
)
70-
)
71-
}
72-
73-
suspend fun emitState() {
74-
emit(currentState)
75-
}
76-
77-
val targetPath: APath<*> get() = currentState.path
78-
}
43+
private val LocationLoaderContext<ExplorerLocation.Directory>.targetPath: APath<*>
44+
get() = state.path
7945

8046
fun loadDirectory(path: APath<*>): Flow<ExplorerLocation> {
8147
return pathPermissionCheck.monitor(path).flatMapLatest { permissionState ->
8248
flow {
8349
log(tag, INFO) { "loadDirectory(): Loading directory with permission state: $permissionState" }
84-
val context = LoaderContext(path, permissionState, ::emit)
50+
val context = LocationLoaderContext(
51+
initialState = ExplorerLocation.Directory(
52+
path = path,
53+
permissionState = permissionState,
54+
progress = Progress.Data(
55+
primary = caString {
56+
it.getString(
57+
R.string.explorer_loader_progress_directory_loading,
58+
path.userReadablePath.get(it)
59+
)
60+
},
61+
)
62+
),
63+
emit = ::emit
64+
)
8565
context.emitState()
8666

8767
context.updateProgressMsg(R.string.explorer_loader_progress_directory_permissions)
@@ -103,7 +83,7 @@ class DirectoryLocationLoader @AssistedInject constructor(
10383
}
10484
}
10585

106-
private suspend fun LoaderContext.loadFileSystemInfo() {
86+
private suspend fun LocationLoaderContext<ExplorerLocation.Directory>.loadFileSystemInfo() {
10787
log(tag) { "loadFileSystemInfo(): Loading file system info for $targetPath" }
10888

10989
updateProgressMsg(R.string.explorer_loader_progress_directory_filesystem)
@@ -119,7 +99,7 @@ class DirectoryLocationLoader @AssistedInject constructor(
11999
}
120100
}
121101

122-
private suspend fun LoaderContext.loadPeek() {
102+
private suspend fun LocationLoaderContext<ExplorerLocation.Directory>.loadPeek() {
123103
log(tag) { "loadPeek(): Loading peek for $targetPath" }
124104
updateProgressMsg(R.string.explorer_loader_progress_directory_content)
125105

@@ -138,7 +118,7 @@ class DirectoryLocationLoader @AssistedInject constructor(
138118
}
139119
}
140120

141-
private suspend fun LoaderContext.loadContent() {
121+
private suspend fun LocationLoaderContext<ExplorerLocation.Directory>.loadContent() {
142122
log(tag) { "loadContent(): Loading content: $targetPath" }
143123
updateProgressMsg(R.string.explorer_loader_progress_directory_content_details)
144124

@@ -192,7 +172,7 @@ class DirectoryLocationLoader @AssistedInject constructor(
192172
}
193173
}
194174

195-
private suspend fun LoaderContext.loadContentExtended() {
175+
private suspend fun LocationLoaderContext<ExplorerLocation.Directory>.loadContentExtended() {
196176
log(tag) { "loadContentExtended(): Loading content extended: $targetPath" }
197177
updateProgressMsg(R.string.explorer_loader_progress_directory_content_extended)
198178

@@ -213,6 +193,9 @@ class DirectoryLocationLoader @AssistedInject constructor(
213193
// Count children for directories
214194
val childCounts = mutableMapOf<String, Int>()
215195
extendedLookups.values.forEach { lookup ->
196+
// Check if cancelled before processing next item
197+
coroutineContext.ensureActive()
198+
216199
if (lookup.fileType == FileType.DIRECTORY) {
217200
try {
218201
val children = gatewaySwitch.listFiles(lookup.lookedUp)

app-workspace-explorer/src/main/java/eu/darken/butler/explorer/core/engine/ExplorerLocation.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import eu.darken.butler.explorer.core.ExplorerNavigation
66
import eu.darken.butler.workspace.core.permissions.PermissionState
77

88
sealed interface ExplorerLocation {
9+
val locationId: String
910
val items: List<ExplorerItem>?
1011
val info: LocationInfo?
1112
val permissionState: PermissionState
@@ -21,6 +22,9 @@ sealed interface ExplorerLocation {
2122
override val permissionState: PermissionState = PermissionState(),
2223
override val progress: Progress.Data? = Progress.Data(),
2324
) : ExplorerLocation {
25+
26+
override val locationId: String get() = "location://home"
27+
2428
data class Info(
2529
val shortcutCount: Int,
2630
val totalDeviceStorage: Long? = null,
@@ -34,6 +38,9 @@ sealed interface ExplorerLocation {
3438
override val permissionState: PermissionState = PermissionState(),
3539
override val progress: Progress.Data? = Progress.Data(),
3640
) : ExplorerLocation {
41+
42+
override val locationId: String get() = "location://device"
43+
3744
data class Info(
3845
val locationCount: Int,
3946
val totalCapacity: Long? = null,
@@ -49,6 +56,9 @@ sealed interface ExplorerLocation {
4956
val path: APath<*>,
5057
val parent: ExplorerNavigation.Target? = null,
5158
) : ExplorerLocation {
59+
60+
override val locationId: String get() = "location://directory/${path.path}"
61+
5262
data class Info(
5363
val fileCount: Int? = null,
5464
val directoryCount: Int? = null,

app-workspace-explorer/src/main/java/eu/darken/butler/explorer/core/engine/ExplorerLocationExtensions.kt

Lines changed: 0 additions & 8 deletions
This file was deleted.

app-workspace-explorer/src/main/java/eu/darken/butler/explorer/core/engine/HomeLocationLoader.kt

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ import androidx.compose.material.icons.Icons
77
import androidx.compose.material.icons.twotone.PhoneAndroid
88
import dagger.hilt.android.qualifiers.ApplicationContext
99
import eu.darken.butler.common.ca.toCaString
10+
import eu.darken.butler.common.debug.logging.Logging.Priority.*
1011
import eu.darken.butler.common.debug.logging.log
1112
import eu.darken.butler.common.debug.logging.logTag
13+
import eu.darken.butler.common.progress.Progress
1214
import eu.darken.butler.explorer.R
1315
import eu.darken.butler.explorer.core.ExplorerNavigation
1416
import eu.darken.butler.workspace.core.permissions.PermissionState
@@ -35,10 +37,19 @@ class HomeLocationLoader @Inject constructor(
3537
}
3638

3739
fun loadHome(): Flow<ExplorerLocation> = flow {
38-
log(tag) { "loadHome(): Loading home location" }
40+
log(tag, INFO) { "loadHome(): Loading home location" }
3941

40-
var result = ExplorerLocation.Home()
41-
emit(result)
42+
val permissionState = checkLocationPermissions()
43+
val context = LocationLoaderContext(
44+
initialState = ExplorerLocation.Home(
45+
permissionState = permissionState,
46+
progress = Progress.Data(
47+
primary = R.string.explorer_loader_progress_home_loading.toCaString(),
48+
),
49+
),
50+
emit = ::emit
51+
)
52+
context.emitState()
4253

4354
val shortcuts = listOf(
4455
ExplorerItem.Shortcut(
@@ -53,7 +64,7 @@ class HomeLocationLoader @Inject constructor(
5364
val stat = try {
5465
StatFs(Environment.getDataDirectory().path)
5566
} catch (e: Exception) {
56-
log(tag) { "loadHome(): Failed to get storage info: ${e.message}" }
67+
log(tag, WARN) { "loadHome(): Failed to get storage info: ${e.message}" }
5768
null
5869
}
5970

@@ -63,15 +74,14 @@ class HomeLocationLoader @Inject constructor(
6374
usedStorage = stat?.let { it.totalBytes - it.availableBytes },
6475
)
6576

66-
log(tag) { "loadHome(): Created home with ${shortcuts.size} shortcuts" }
67-
68-
result = ExplorerLocation.Home(
69-
items = shortcuts,
70-
info = info,
71-
permissionState = checkLocationPermissions(),
72-
progress = null,
73-
)
74-
emit(result)
77+
log(tag, INFO) { "loadHome(): Created home with ${shortcuts.size} shortcuts" }
7578

79+
context.updateState {
80+
copy(
81+
items = shortcuts,
82+
info = info,
83+
progress = null,
84+
)
85+
}
7686
}
7787
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package eu.darken.butler.explorer.core.engine
2+
3+
import androidx.annotation.StringRes
4+
import eu.darken.butler.common.ca.toCaString
5+
6+
/**
7+
* Shared context for location loaders that manages incremental state updates and emissions.
8+
* Provides a consistent pattern for multi-stage loading with progress tracking.
9+
*
10+
* @param T The specific ExplorerLocation type being loaded
11+
* @param initialState The initial location state with progress
12+
* @param emit Function to emit state updates to the flow collector
13+
*/
14+
internal class LocationLoaderContext<T : ExplorerLocation>(
15+
initialState: T,
16+
private val emit: suspend (T) -> Unit,
17+
) {
18+
private var currentState = initialState
19+
20+
val state: T get() = currentState
21+
22+
suspend fun updateState(transform: T.() -> T) {
23+
currentState = currentState.transform()
24+
emit(currentState)
25+
}
26+
27+
suspend fun emitState() {
28+
emit(currentState)
29+
}
30+
}
31+
32+
/**
33+
* Updates the secondary progress message for the current loading stage.
34+
* Handles nullable progress safely across all ExplorerLocation types.
35+
*/
36+
internal suspend fun <T : ExplorerLocation> LocationLoaderContext<T>.updateProgressMsg(
37+
@StringRes msg: Int
38+
) = updateState {
39+
@Suppress("UNCHECKED_CAST")
40+
when (this) {
41+
is ExplorerLocation.Device -> copy(
42+
progress = progress?.copy(secondary = msg.toCaString())
43+
) as T
44+
is ExplorerLocation.Directory -> copy(
45+
progress = progress?.copy(secondary = msg.toCaString())
46+
) as T
47+
is ExplorerLocation.Home -> copy(
48+
progress = progress?.copy(secondary = msg.toCaString())
49+
) as T
50+
}
51+
}

app-workspace-explorer/src/main/java/eu/darken/butler/explorer/ui/explorer/ExplorerWorkspaceViewModel.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,6 @@ import eu.darken.butler.explorer.core.FilterState
4141
import eu.darken.butler.explorer.core.PatternMatcher
4242
import eu.darken.butler.explorer.core.engine.ExplorerItem
4343
import eu.darken.butler.explorer.core.engine.ExplorerLocation
44-
import eu.darken.butler.explorer.core.engine.locationId
4544
import eu.darken.butler.explorer.core.operations.ExplorerCommand
4645
import eu.darken.butler.explorer.core.picker.PickerConfig
4746
import eu.darken.butler.explorer.core.sorting.ExplorerItemSorter

app-workspace-explorer/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@
135135
<string name="explorer_loader_progress_device_locations">Loading storage locations</string>
136136
<string name="explorer_loader_progress_device_filesystem">Loading storage info</string>
137137

138+
<string name="explorer_loader_progress_home_loading">Loading home</string>
139+
138140
<!-- InfoBar -->
139141
<plurals name="explorer_infobar_selected_count">
140142
<item quantity="one">%d selected</item>

0 commit comments

Comments
 (0)