Skip to content

Commit 53af5a4

Browse files
committed
Merge branch 'worktree-searcher'
2 parents f09b1a7 + 3e125f6 commit 53af5a4

10 files changed

Lines changed: 181 additions & 61 deletions

File tree

app-common/src/main/res/values/strings.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,11 @@
192192
<string name="setup_saf_card_title">Storage Access Framework</string>
193193
<string name="setup_manage_storage_card_title">Manage external storage</string>
194194
<string name="setup_inventory_card_title">App inventory</string>
195+
196+
<!-- Common permission strings -->
197+
<string name="common_permission_required_title">Permission Required</string>
198+
<string name="common_permission_additional_required">Additional permissions are required to access: %1$s</string>
199+
<string name="common_permission_open_setup_action">Open Setup</string>
200+
<string name="common_permission_storage_manage_description">Grant storage access to manage all files.</string>
201+
<string name="common_permission_saf_required_description">Folder access permission required.</string>
195202
</resources>

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

Lines changed: 6 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,10 @@ import eu.darken.butler.common.files.APath
1717
import eu.darken.butler.common.files.GatewaySwitch
1818
import eu.darken.butler.common.files.LocalPath
1919
import eu.darken.butler.common.hasApiLevel
20-
import eu.darken.butler.common.permissions.Permission
20+
import eu.darken.butler.workspace.core.permissions.PathPermissionChecker
2121
import eu.darken.butler.explorer.R
2222
import eu.darken.butler.explorer.core.ExplorerNavigation
23-
import eu.darken.butler.workspace.core.permissions.WorkspacePermissions
24-
import eu.darken.butler.workspace.core.permissions.PermissionRequirement
23+
import eu.darken.butler.workspace.core.permissions.PermissionState
2524
import kotlinx.coroutines.Dispatchers
2625
import kotlinx.coroutines.flow.Flow
2726
import kotlinx.coroutines.flow.flow
@@ -31,73 +30,31 @@ import javax.inject.Inject
3130
class ExplorerEngine @Inject constructor(
3231
@ApplicationContext private val context: Context,
3332
private val gatewaySwitch: GatewaySwitch,
33+
private val pathPermissionChecker: PathPermissionChecker,
3434
) {
3535

3636
internal var subTag: String = ""
3737
private val tag by lazy { logTag("Explorer", "Engine", subTag) }
3838

39-
private fun checkLocationPermissions(target: ExplorerNavigation.Target): WorkspacePermissions {
39+
private fun checkLocationPermissions(target: ExplorerNavigation.Target): PermissionState {
4040
log(tag) { "checkLocationPermissions(): Checking permissions for $target" }
4141

4242
return when (target) {
4343
is ExplorerNavigation.Target.Home,
4444
is ExplorerNavigation.Target.Device -> {
4545
// Home and Device views don't require permissions
46-
WorkspacePermissions(
46+
PermissionState(
4747
requirements = emptyList(),
4848
hasSufficientPermissions = true,
4949
missingCritical = emptyList(),
5050
)
5151
}
5252
is ExplorerNavigation.Target.Directory -> {
53-
checkDirectoryPermissions(target.path)
53+
pathPermissionChecker.check(target.path)
5454
}
5555
}
5656
}
5757

58-
private fun checkDirectoryPermissions(path: APath): WorkspacePermissions {
59-
val pathString = when (path) {
60-
is LocalPath -> path.path
61-
else -> path.path
62-
}
63-
64-
// Check if this is internal storage that requires permissions
65-
val internalStoragePath = Environment.getExternalStorageDirectory().absolutePath
66-
val requiresStoragePermission = pathString.startsWith(internalStoragePath) ||
67-
pathString.startsWith("/storage/emulated/") ||
68-
pathString.startsWith("/sdcard")
69-
70-
if (!requiresStoragePermission) {
71-
// App-specific directories or other paths that don't need special permissions
72-
return WorkspacePermissions(
73-
requirements = emptyList(),
74-
hasSufficientPermissions = true,
75-
missingCritical = emptyList(),
76-
)
77-
}
78-
79-
// Determine which storage permission is needed based on API level
80-
val requiredPermission = when {
81-
hasApiLevel(30) -> Permission.MANAGE_EXTERNAL_STORAGE
82-
else -> Permission.WRITE_EXTERNAL_STORAGE
83-
}
84-
85-
val isGranted = requiredPermission.isGranted(context)
86-
log(tag) { "checkDirectoryPermissions(): $requiredPermission isGranted=$isGranted for path=$pathString" }
87-
88-
val requirement = PermissionRequirement(
89-
permission = requiredPermission,
90-
isRequired = true,
91-
reason = R.string.explorer_permission_generic_description.toCaString(),
92-
alternativeAccess = null,
93-
)
94-
95-
return WorkspacePermissions(
96-
requirements = listOf(requirement),
97-
hasSufficientPermissions = isGranted,
98-
missingCritical = if (!isGranted) listOf(requiredPermission) else emptyList(),
99-
)
100-
}
10158

10259
private suspend fun getHomeEntry(): ExplorerLocation = withContext(Dispatchers.IO) {
10360
val shortcuts = listOf(

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,19 @@ package eu.darken.butler.explorer.core.engine
22

33
import eu.darken.butler.common.files.APath
44
import eu.darken.butler.explorer.core.ExplorerNavigation
5-
import eu.darken.butler.workspace.core.permissions.WorkspacePermissions
5+
import eu.darken.butler.workspace.core.permissions.PermissionState
66

77
sealed interface ExplorerLocation {
88
val items: List<ExplorerItem>
99
val info: LocationInfo?
10-
val permissionState: WorkspacePermissions
10+
val permissionState: PermissionState
1111

1212
sealed interface LocationInfo
1313

1414
data class Home(
1515
override val items: List<ExplorerItem>,
1616
override val info: Info? = null,
17-
override val permissionState: WorkspacePermissions = WorkspacePermissions(),
17+
override val permissionState: PermissionState = PermissionState(),
1818
) : ExplorerLocation {
1919
data class Info(
2020
val shortcutCount: Int,
@@ -26,7 +26,7 @@ sealed interface ExplorerLocation {
2626
data class Device(
2727
override val items: List<ExplorerItem>,
2828
override val info: Info? = null,
29-
override val permissionState: WorkspacePermissions = WorkspacePermissions(),
29+
override val permissionState: PermissionState = PermissionState(),
3030
) : ExplorerLocation {
3131
data class Info(
3232
val storageCount: Int,
@@ -40,7 +40,7 @@ sealed interface ExplorerLocation {
4040
val parent: ExplorerNavigation.Target? = null,
4141
override val items: List<ExplorerItem.PathItem> = emptyList(),
4242
override val info: Info? = null,
43-
override val permissionState: WorkspacePermissions = WorkspacePermissions(),
43+
override val permissionState: PermissionState = PermissionState(),
4444
) : ExplorerLocation {
4545
data class Info(
4646
val fileCount: Int,

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import eu.darken.butler.workspace.core.Workspace
3636
import eu.darken.butler.workspace.core.WorkspaceProvider
3737
import eu.darken.butler.workspace.core.clipboard.ClipboardClip
3838
import eu.darken.butler.workspace.core.clipboard.ClipboardRepo
39-
import eu.darken.butler.workspace.core.permissions.WorkspacePermissions
39+
import eu.darken.butler.workspace.core.permissions.PermissionState
4040
import kotlinx.coroutines.flow.Flow
4141
import kotlinx.coroutines.flow.MutableStateFlow
4242
import kotlinx.coroutines.flow.combine
@@ -114,7 +114,7 @@ class ExplorerWorkspaceViewModel @AssistedInject constructor(
114114
availableActions = availableActions,
115115
dialogState = dialogState,
116116
clipboardEntries = clipboard.entries,
117-
permissionState = wsState.currentLocation?.permissionState ?: WorkspacePermissions(),
117+
permissionState = wsState.currentLocation?.permissionState ?: PermissionState(),
118118
)
119119
}.asStateFlow()
120120

@@ -483,7 +483,7 @@ class ExplorerWorkspaceViewModel @AssistedInject constructor(
483483
val availableActions: List<ExplorerAction> = emptyList(),
484484
val dialogState: ExplorerDialogState = ExplorerDialogState.None,
485485
val clipboardEntries: List<ClipboardClip> = emptyList(),
486-
val permissionState: WorkspacePermissions = WorkspacePermissions(),
486+
val permissionState: PermissionState = PermissionState(),
487487
)
488488

489489
data class ClipboardState(

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ import eu.darken.butler.common.compose.Preview2
3131
import eu.darken.butler.common.compose.PreviewWrapper
3232
import eu.darken.butler.common.permissions.Permission
3333
import eu.darken.butler.explorer.R
34-
import eu.darken.butler.workspace.core.permissions.WorkspacePermissions
34+
import eu.darken.butler.workspace.core.permissions.PermissionState
3535
import eu.darken.butler.workspace.core.permissions.PermissionRequirement
3636

3737
@Composable
3838
fun PermissionRequestCard(
39-
permissionState: WorkspacePermissions,
39+
permissionState: PermissionState,
4040
onNavigateToSetup: () -> Unit,
4141
modifier: Modifier = Modifier,
4242
) {
@@ -127,7 +127,7 @@ fun PermissionRequestCard(
127127
private fun PermissionRequestCardPreview() {
128128
PreviewWrapper {
129129
PermissionRequestCard(
130-
permissionState = WorkspacePermissions(
130+
permissionState = PermissionState(
131131
requirements = listOf(
132132
PermissionRequirement(
133133
permission = Permission.MANAGE_EXTERNAL_STORAGE,

app-workspace-searcher/src/main/java/eu/darken/butler/searcher/ui/search/SearcherWorkspacePage.kt

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ import androidx.compose.material.icons.twotone.Schedule
4242
import androidx.compose.material.icons.twotone.Search
4343
import androidx.compose.material.icons.twotone.TextFormat
4444
import androidx.compose.material.icons.twotone.FormatQuote
45+
import androidx.compose.material.icons.twotone.Storage
4546
import androidx.compose.material.icons.automirrored.twotone.WrapText
4647
import androidx.compose.ui.text.style.TextOverflow
48+
import androidx.compose.ui.text.font.FontFamily
4749
import androidx.compose.material3.AlertDialog
4850
import androidx.compose.material3.Card
4951
import androidx.compose.material3.CardDefaults
52+
import androidx.compose.material3.Button
53+
import androidx.compose.material3.ButtonDefaults
5054
import androidx.compose.material3.CircularProgressIndicator
5155
import androidx.compose.material3.Icon
5256
import androidx.compose.material3.LinearProgressIndicator
@@ -114,6 +118,7 @@ fun SearcherWorkspacePage(
114118
workspaceButtonState: WorkspaceButtonViewModel.State?,
115119
onWorkspaceAction: (WorkspaceAction) -> Unit,
116120
onNavToWorkspaceManager: () -> Unit,
121+
onOpenSetup: () -> Unit = {},
117122
) {
118123
var searchDebounce by remember { mutableStateOf(false) }
119124
var showClearHistoryDialog by remember { mutableStateOf(false) }
@@ -156,6 +161,69 @@ fun SearcherWorkspacePage(
156161
onNavToWorkspaceManager = onNavToWorkspaceManager
157162
)
158163
}
164+
165+
// Show permission card if needed
166+
if (state.needsPermissions) {
167+
item {
168+
Card(
169+
modifier = Modifier.fillMaxWidth(),
170+
colors = CardDefaults.cardColors(
171+
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
172+
)
173+
) {
174+
Row(
175+
modifier = Modifier
176+
.fillMaxWidth()
177+
.padding(16.dp),
178+
verticalAlignment = Alignment.CenterVertically,
179+
horizontalArrangement = Arrangement.spacedBy(12.dp)
180+
) {
181+
Icon(
182+
imageVector = Icons.TwoTone.Storage,
183+
contentDescription = null,
184+
modifier = Modifier.size(24.dp),
185+
tint = MaterialTheme.colorScheme.error
186+
)
187+
188+
Column(
189+
modifier = Modifier.weight(1f),
190+
verticalArrangement = Arrangement.spacedBy(4.dp)
191+
) {
192+
Text(
193+
text = stringResource(eu.darken.butler.common.R.string.common_permission_required_title),
194+
style = MaterialTheme.typography.titleSmall,
195+
color = MaterialTheme.colorScheme.error
196+
)
197+
198+
Text(
199+
text = stringResource(R.string.searcher_permission_search_description),
200+
style = MaterialTheme.typography.bodySmall,
201+
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
202+
)
203+
204+
Text(
205+
text = state.searchPath.path,
206+
style = MaterialTheme.typography.bodySmall,
207+
fontFamily = FontFamily.Monospace,
208+
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
209+
)
210+
}
211+
212+
Button(
213+
onClick = onOpenSetup,
214+
colors = ButtonDefaults.buttonColors(
215+
containerColor = MaterialTheme.colorScheme.error
216+
)
217+
) {
218+
Text(
219+
text = stringResource(eu.darken.butler.common.R.string.common_permission_open_setup_action),
220+
style = MaterialTheme.typography.labelMedium
221+
)
222+
}
223+
}
224+
}
225+
}
226+
}
159227

160228
// Show search history when no search query
161229
if (state.searchQuery.text.isBlank() && state.searchHistory.isNotEmpty()) {
@@ -420,6 +488,7 @@ fun SearcherWorkspacePageHost(
420488
workspaceButtonState = workspaceButtonState,
421489
onWorkspaceAction = workspaceButtonVm::onWorkspaceAction,
422490
onNavToWorkspaceManager = workspaceButtonVm::onNavToWorkspaceManager,
491+
onOpenSetup = vm::navigateToSetup,
423492
)
424493
}
425494
}

app-workspace-searcher/src/main/java/eu/darken/butler/searcher/ui/search/SearcherWorkspaceViewModel.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import eu.darken.butler.common.coroutine.DispatcherProvider
99
import eu.darken.butler.common.debug.logging.Logging.Priority.*
1010
import eu.darken.butler.common.debug.logging.log
1111
import eu.darken.butler.common.debug.logging.logTag
12+
import eu.darken.butler.workspace.core.permissions.PathPermissionChecker
13+
import eu.darken.butler.common.navigation.Nav
14+
import eu.darken.butler.common.navigation.destSetup
15+
import eu.darken.butler.setup.core.SetupModule
16+
import android.content.Context
17+
import dagger.hilt.android.qualifiers.ApplicationContext
1218
import eu.darken.butler.common.files.APath
1319
import eu.darken.butler.common.files.LocalPath
1420
import eu.darken.butler.common.navigation.NavigationController
@@ -31,11 +37,13 @@ import kotlinx.coroutines.launch
3137
@HiltViewModel(assistedFactory = SearcherWorkspaceViewModel.Factory::class)
3238
class SearcherWorkspaceViewModel @AssistedInject constructor(
3339
@Assisted private val id: Workspace.Id,
40+
@ApplicationContext private val appContext: Context,
3441
dispatchers: DispatcherProvider,
3542
navCtrl: NavigationController,
3643
private val searchEngine: SearchEngine,
3744
private val searchHistory: SearchHistory,
3845
private val searcherSettings: SearcherSettings,
46+
private val pathPermissionChecker: PathPermissionChecker,
3947
) : ViewModel4(dispatchers, logTag("Searcher", "Workspace", id.shortTag, "Page"), navCtrl) {
4048

4149
private val searchQuery = MutableStateFlow(TextFieldValue(""))
@@ -59,6 +67,7 @@ class SearcherWorkspaceViewModel @AssistedInject constructor(
5967

6068
private var activeSearchJob: Job? = null
6169
private var currentSearchId: String? = null
70+
6271

6372
data class SearchState(
6473
val status: Status = Status.IDLE,
@@ -96,6 +105,7 @@ class SearcherWorkspaceViewModel @AssistedInject constructor(
96105
caseSensitive = filter.caseSensitive,
97106
wholeWord = filter.wholeWord,
98107
useRegex = filter.useRegex,
108+
needsPermissions = pathPermissionChecker.check(path).needsPermissions,
99109
)
100110
}.asStateFlow()
101111

@@ -232,6 +242,7 @@ class SearcherWorkspaceViewModel @AssistedInject constructor(
232242
val caseSensitive: Boolean = false,
233243
val wholeWord: Boolean = false,
234244
val useRegex: Boolean = false,
245+
val needsPermissions: Boolean = false,
235246
) {
236247
val isSearching: Boolean
237248
get() = searchState.status == SearchState.Status.SEARCHING
@@ -240,6 +251,17 @@ class SearcherWorkspaceViewModel @AssistedInject constructor(
240251
get() = searchState.results.isNotEmpty()
241252
}
242253

254+
fun navigateToSetup() = launch {
255+
log(tag) { "navigateToSetup(): Opening setup for storage permissions" }
256+
navTo(
257+
Nav.Main.destSetup(
258+
typeFilter = setOf(SetupModule.Type.STORAGE),
259+
requiredTypes = setOf(SetupModule.Type.STORAGE),
260+
autoCloseWhenComplete = true,
261+
)
262+
)
263+
}
264+
243265
@AssistedFactory
244266
interface Factory {
245267
fun create(id: Workspace.Id): SearcherWorkspaceViewModel

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,7 @@
6161
<string name="searcher_status_results_found">%1$d results found</string>
6262
<string name="searcher_status_no_results">No results found</string>
6363
<string name="searcher_status_search_completed">Search completed</string>
64+
65+
<!-- Permission strings -->
66+
<string name="searcher_permission_search_description">Grant permissions to search this location.</string>
6467
</resources>

0 commit comments

Comments
 (0)