CLAUDE.md
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
About Butler
Butler is an open-source Android file explorer with advanced features including root access, ADB integration, and multiple workspace support. It’s built using modern Android development practices with Jetpack Compose, Kotlin Coroutines, and Hilt dependency injection.
Butler uses a workspace concept similar to browser tabs with 4 main workspace types:
- EXPLORER: File browsing and management
- SEARCHER: File search functionality
- EDITOR: Text editing
- TEMPLATES: Workspace template management
Development Commands
Building the Project
# Build debug version (FOSS flavor) - main app
./gradlew :app:compileFossDebugKotlin --no-daemon
# Build specific modules (use compileDebugKotlin, not compileFossDebugKotlin for modules)
./gradlew :app-workspace:compileDebugKotlin --no-daemon
./gradlew :app-workspace-explorer:compileDebugKotlin --no-daemon
./gradlew :app-workspace-searcher:compileDebugKotlin --no-daemon
./gradlew :app-workspace-editor:compileDebugKotlin --no-daemon
./gradlew :app-workspace-templates:compileDebugKotlin --no-daemon
# Build release version
./gradlew :app:bundleFossRelease
# Clean build
./gradlew clean
Build Context Management
When running gradle build commands, use the Task tool with a sub-agent to keep verbose build output isolated from the main context:
Default approach (preferred):
- Use Task tool → general-purpose agent → run gradle command
- Sub-agent should report back only:
- Success/failure status
- Compilation errors (if any) with file locations
- Count of warnings (without full output)
Run gradle directly in main context only when:
- User explicitly requests to see full build output
- Quick verification of available gradle tasks (
./gradlew tasks)
This aligns with the “Agent instructions” principle of maintaining focused contexts and optimizes token usage.
Testing
# Run all unit tests in the project
./gradlew testDebugUnitTest
# Run unit tests for a specific module
./gradlew :app-common-io:testDebugUnitTest
# Run a specific test class
./gradlew :app-common-io:testDebugUnitTest --tests "eu.darken.butler.common.files.operations.GenericPathCopyTest"
# Run a specific test method
./gradlew :app-common-io:testDebugUnitTest --tests "eu.darken.butler.common.files.operations.GenericPathCopyTest.testCopyFile"
# Run instrumented tests (on connected device/emulator)
./gradlew connectedAndroidTest
Test Context Management
When running gradle test commands, use the Task tool with a sub-agent to keep verbose test output isolated from the main context:
Default approach (preferred):
- Use Task tool → general-purpose agent → run gradle test command
- Sub-agent should report back only:
- Success/failure status
- Test failures (if any) with file locations and error messages
- Count of passed/skipped tests (without full output)
Run gradle directly in main context only when:
- User explicitly requests to see full test output
- Quick verification of test availability
This aligns with the “Agent instructions” principle of maintaining focused contexts and optimizes token usage.
Debugging
Taking Screenshots via ADB
When debugging UI issues, layout problems, or visual elements:
# Use the screenshot script (preferred method)
./.claude/scripts/screenshot.sh
# Or with a custom filename
./.claude/scripts/screenshot.sh my-ui-bug
# Manual method (if script unavailable)
mkdir -p .claude/tmp && adb shell screencap -p > .claude/tmp/screenshot.png
Use cases:
- Verifying UI element positioning (badges, overlays, spacing)
- Checking visual appearance of components
- Confirming layout issues before/after fixes
- Documenting visual bugs
Fastlane Deployment
# Deploy beta version
fastlane android beta
# Deploy production version
fastlane android production
Development Tooling
Test File Structure Creator
Located in
tooling/test-files/, this tool creates comprehensive test file structures on Android devices for testing Butler’s file operations, navigation, and performance.
Quick Usage:
# 1. Check connected devices
adb devices -l
# 2. Push and execute script (use -s <SERIAL> for specific device)
adb push tooling/test-files/create-test-files.sh /sdcard/
adb shell "sh /sdcard/create-test-files.sh /sdcard/aButlerTests"
What it creates:
adirwithlargefiles/- 8 files from 100MB to 8GB with random data (~16.5GB total)adirwithmanyfiles/- 4,000 small files (0-50KB each, ~100MB total)adirwithnesteddata/- Balanced tree structure (~1,500 folders, ~3,500 files, 10 levels deep)
Requirements:
- 18GB free space on device
- 15-25 minutes runtime (varies by device)
Full documentation: See
tooling/test-files/README.md for detailed usage, troubleshooting, and customization options.
Architecture Overview
Build Flavors
- FOSS: Open source version without Google Play dependencies.
- GPLAY: Google Play version with additional features.
Module Structure
Core Application
app: Main application module with entry point, flavor-specific implementations, and setup flow.
Foundation Modules
app-common: Core shared utilities, base architecture components, custom ViewModel hierarchy, theming system.app-common-test: Testing utilities, helpers, and base test classes for all modules.
Platform Integration Modules
app-common-io: File I/O operations, abstract path system (APath), gateway pattern for file access methods.app-common-root: Root access functionality and root-based file operations.app-common-adb: Android Debug Bridge integration via Shizuku API.app-common-shell: Shell operations and reactive command execution with FlowShell.app-common-pkgs: Package management utilities and package event handling.
Workspace Modules
app-workspace: Core workspace framework, base classes, and tab-like workspace management.app-workspace-explorer: File browsing workspace with navigation, file operations, sorting/filtering.app-workspace-searcher: File search workspace with search engine, filters, and result caching.app-workspace-editor: Text editing workspace with chunked buffer system for large files.app-workspace-templates: Workspace template management and type switching.
Modal Workspace Pattern
Butler supports modal workspaces - workspaces that render as full-screen overlays instead of tabs. This pattern enables workspace-to-workspace interactions like file/folder pickers, while maintaining full workspace capabilities.
Core Concept: Sub-Workspaces
A sub-workspace is a workspace created by another workspace to return a result (e.g., Explorer picker launched by Searcher). Sub-workspaces:
- Render as full-screen modals that block background interaction
- Maintain full workspace capabilities (navigation, operations, permissions)
- Automatically close when their parent workspace closes
- Return results via
WorkspaceEvent.PickerResult
Architectural Principle: The domain layer exposes workspace relationships (callerWorkspaceId), the UI layer decides presentation (modal vs tab).
Creating a Result-Returning Workspace
1. Implement ArgumentsForResult Interface
@Parcelize
data class ExplorerPickerArguments(
val startPath: APath<*>? = null,
val pickerMode: PickerMode = PickerMode.DIRECTORY,
override val callerWorkspaceId: Workspace.Id? = null, // Required
) : Workspace.ArgumentsForResult {
@IgnoredOnParcel
override val type: Workspace.Type = Workspace.Type.EXPLORER
}
Key Points:
- Inherit from
Workspace.ArgumentsForResult(not justWorkspace.Arguments) - Override
callerWorkspaceIdproperty to expose the calling workspace - This enables generic parent-child tracking across all workspace types
2. Expose Relationship in Workspace.Info
override val info: Flow<Workspace.Info> = combine(
// ... your state flows ...
) { /* ... */ ->
Workspace.Info(
id = id,
type = type,
title = /* ... */,
callerWorkspaceId = pickerConfig?.callerWorkspaceId, // Expose relationship
)
}
3. Return Results via Convenience Functions
import eu.darken.butler.workspace.core.returnResult
import eu.darken.butler.workspace.core.cancelResult
// In your confirmation method - return result and close:
workspaceRemote.returnResult(
WorkspaceEvent.PickerResult(
workspaceId = id,
callerWorkspaceId = config.callerWorkspaceId,
selectedPaths = selectedPaths
)
)
// In your cancellation method - emit cancellation and close:
workspaceRemote.cancelResult(
workspaceId = id,
callerWorkspaceId = config.callerWorkspaceId,
)
Note: The returnResult() and cancelResult() convenience functions combine event emission with automatic workspace closure. For more complex flows requiring multiple events before closing, use workspaceRemote.emitEvent() and workspaceRemote.execute(Close()) separately.
Launching a Modal Workspace
// In calling workspace (e.g., SearcherWorkspaceViewModel):
// 1. Create the modal workspace
val result = workspaceRemote.execute(
WorkspaceAction.Create(
type = Workspace.Type.EXPLORER,
arguments = ExplorerPickerArguments(
startPath = currentPath,
pickerMode = PickerMode.DIRECTORY,
callerWorkspaceId = id // Pass your workspace ID
)
)
) as WorkspaceAction.Create.Result
// 2. Listen for results using convenience extension
import eu.darken.butler.workspace.core.handleResult
workspaceRemote.events
.handleResult<WorkspaceEvent.PickerResult>(callerWorkspaceId = id) { result ->
// Handle result
val selectedPath = result.selectedPaths.firstOrNull()
updateSearchPath(selectedPath)
// Workspace closes automatically - no manual close needed
}
.launchInViewModel()
UI Rendering
The UI layer automatically renders sub-workspaces as modals:
// WorkspacesViewModel.State derives presentation:
val tabWorkspaces: List<Workspace.Info>
get() = state.infos.filter { !it.isSubWorkspace } // Normal workspaces
val modalWorkspace: Workspace.Info?
get() = state.infos.firstOrNull { it.isSubWorkspace } // Modal overlay
No workspace code changes needed - the UI layer uses Workspace.Info.isSubWorkspace (derived from callerWorkspaceId != null) to decide rendering.
Parent-Child Lifecycle
Parent-child relationships enable automatic cleanup:
// In WorkspaceRepo.execute(WorkspaceAction.Close):
val childWorkspaces = _workspaces.value.filter { ws ->
val info = ws.info.first()
info.callerWorkspaceId == action.id // Find children of closing workspace
}
// Auto-close all child workspaces when parent closes
Benefits:
- Prevents orphaned picker workspaces
- No manual tracking needed
- Works for any
ArgumentsForResultimplementation
Example Use Cases
- File/Folder Picker (Implemented)
- Searcher launches Explorer picker to select search directory
- Full Explorer features: navigation, permissions, folder creation
- Returns selected path, closes automatically
- File Picker for Editor (Future)
- Editor launches Explorer picker to open files
- Multi-select support for opening multiple files
- Template Picker (Future)
- Any workspace can launch Templates picker to switch types
- Returns selected template, workspace morphs
Best Practices
Domain Layer:
- ❌ Don’t expose UI concepts like
presentationMode,displayStyle,renderType - ✅ Do expose domain relationships like
callerWorkspaceId,parentId,ownerWorkspaceId - Let UI layer derive presentation from domain data
Result Events:
- All result events implement
WorkspaceEvent.ResultEventinterface - Use specific event types for different result payloads (e.g.,
PickerResult) - Include both
workspaceIdandcallerWorkspaceIdfor robust routing - Use
returnResult()convenience function for common “return-and-close” pattern - Use
cancelResult()to emit cancellation event when dismissed without result - For complex flows (preview, validation), emit events separately and close manually
Handling Results:
- Use
handleResult<T>()flow extension for automatic filtering and type-safe handling - No manual workspace close needed -
handleResult()filters terminal events - For multiple result types, chain multiple
handleResult()calls - Example:
.handleResult<PickerResult>(id) { /* handle */ }.launchIn(scope)
Naming:
- Arguments:
[Type]PickerArguments(e.g.,ExplorerPickerArguments) - Config:
PickerConfig(stored in workspace instance, not flowed) - Events:
[Type]Result(e.g.,PickerResult) - All implement
ResultEventfor consistent handling
Coding Standards
- Package by feature, not by layer.
- All user facing strings should be extract to
values/strings.xmland translated for all other languages too. - Prefer adding to existing files unless creating new logical components.
- Composable organization:
- Reusable composables should be in their own files (e.g.,
ButlerIcon.kt,ColoredTitleText.kt) - Screen-specific composables can remain in the screen file unless the file grows too large
- Extract screen-specific composables to separate files when the main file exceeds ~200 lines
- Always add
@Preview2functions for ALL composables (including screen-level pages):- For simple composables: Create standard previews with representative data
- For complex screens with Flow/ViewModel dependencies:
- Use mock state objects with
flowOf()for Flow parameters - Create multiple preview scenarios where applicable (empty state, loading, with data, error states)
- Example:
SearcherWorkspacePageshould have previews showing different UI states
- Use mock state objects with
- Place compose previews below the composable being previewed
- Preview function naming:
ComponentNamePreview()and mark asprivate
- Reusable composables should be in their own files (e.g.,
- Write tests for web APIs and serialized data.
- No UI tests required.
- Use FOSS debug flavor for local testing.
- Don’t add code comments for obvious code.
- Write minimalistic and concise code (omit comments).
- Prefer flow based solutions.
- Prefer reactive programming.
- When using
ifthat is not single-line, always use brackets. - Always add trailing commas.
- In
@Composablefunctions, the parametermodifier: Modifier = Modifier,should be the first parameter.
Agent instructions
- Reminder: Our core principle is to maintain focused contexts for both yourself (the orchestrator/main agent) and each sub-agent. Therefore, please use the Task tool to delegate suitable tasks to sub-agents to improve task efficiency and optimize token usage.
- Be critical.
- Challenge suggestions.
Development Guidelines
General
- Single Activity architecture with Compose Navigation3.
- Reactive programming with Kotlin Flow and StateFlow.
- Centralized error handling with
ErrorEventHandler. - DataStore-based settings with kotlinx serialization.
- When accessing settings values, use the
.value()extension function instead of.flow.first() - Example:
searcherSettings.defaultSearchPath.value()notsearcherSettings.defaultSearchPath.flow.first() - For setting values use:
searcherSettings.someSetting.value(newValue)
- When accessing settings values, use the
- Jetpack Compose for UI.
- Hilt for dependency injection.
- Kotlin Coroutines & Flow for async operations.
- KotlinX for JSON serialization.
- Coil for image loading.
- Room for database operations.
- Use
FlowCombineExtensionsinstead of nesting multiple combine statements. - Prefer Kotlin standard library types over Java equivalents:
- Use
kotlin.Uuidinstead ofjava.util.UUID - Use
kotlin.time.Instantinstead ofjava.time.Instant - Use
kotlin.time.Durationinstead ofjava.time.Duration - Use Kotlin collections and their extension functions
- Use
- Check if
@OptInannotations are actually necessary before adding them:- Many experimental APIs (like
ExperimentalMaterial3Api) are already enabled project-wide via gradle compile flags (freeCompilerArgs) - Only add
@OptInif you get a compilation error without it
- Many experimental APIs (like
Dependency Injection
- Hilt/Dagger throughout the application.
@AndroidEntryPointfor Activities/Fragments.@HiltViewModelfor ViewModels.- Modular DI setup across different modules.
User Interface
- Full Jetpack Compose with Material 3.**
- Custom theming system (
ButlerTheme,ButlerColors). - Edge-to-edge display support.
- Use icons out of the
androidx.compose.material.icons.twotonepackage where possible. - When creating compose previews, use the
@Preview2annotation, and wrap the UI element in aPreviewWrapper.
Localization
- All user-facing texts need to be extracted to a
strings.xmlresources file to be localizable. - Composables should access strings by
stringResource(id = R.string.my_string). - Backend classes (those in the
core) packages and other non-composables should useCAStringto provide localized strings.R.string.xxx.toCaString()R.string.xxx.toCaString("Argument")caString { getString(R.plurals.xxx, count, count) }
- Localized strings with multiple arguments should use ordered placeholders (i.e.
%1$s is %2$d). - Use ellipsis characters (
…) instead of 3 manual dots (...). - Use the
strings.xmlfile that belongs to respective feature module. - General texts that are used through-out multiple modules should be placed in the
strings.xmlfile of theapp-commonmodule. - Before creating a new entry, check if
strings.xmlfile in theapp-commonmodule already contains a general version. - String IDs should be prefixed with their respective module name. Re-used strings should be prefixed with
generalorcommon. - Where possible string IDs should not contain implementation details.
- Postfix with
_actioninstead of prefixing withbutton_. - Instead of
module_screen_button_openit should bemodule_screen_open_action
- Postfix with
MVVM with Custom ViewModel Hierarchy
ViewModel1→ViewModel2→ViewModel3→ViewModel4.ViewModel4adds navigation capabilities.- Uses Hilt for assisted injection.
Business Logic
General
- Abstract path system (
APath,RawPath).APathoffers path segment infos viasegments. Use that instead of path splitting.
- Gateway pattern for different file access methods.
- Support for root, ADB, and shell operations.
Type Converters and Serialization
- When creating type converters or serialization tools, consider the scope:
- Global types (e.g.,
Instant,Duration,Uuid): Place converters in theapp-commonmodule for reuse across the entire application - Workspace-specific types: Place converters in the respective workspace module (e.g., editor-specific converters in
app-workspace-editor) - This ensures proper code organization and prevents duplication
- Global types (e.g.,
Logging
Butler uses a custom logging system (Logging.kt) for comprehensive debugging and monitoring.
Required Imports
import eu.darken.butler.common.debug.logging.Logging.Priority.*
import eu.darken.butler.common.debug.logging.asLog
import eu.darken.butler.common.debug.logging.log
import eu.darken.butler.common.debug.logging.logTag
Priority Levels
- VERBOSE (2): Most detailed logging for deep debugging
- DEBUG (3): General debugging information (default priority)
- INFO (4): Important informational messages and milestones
- WARN (5): Warning conditions that need attention
- ERROR (6): Error conditions and exceptions
- ASSERT (7): Critical assertions and “WTF” moments
Tag Conventions
Create hierarchical tags following the pattern: Module:Component:Instance:Page
// Simple component
private val tag = logTag("ComponentName")
// Module with component
private val tag = logTag("Editor", "Engine")
// Workspace with instance ID
private val tag = logTag("Explorer", "Workspace", id.shortTag)
// ViewModel with page context
private val tag = logTag("Searcher", "Workspace", id.shortTag, "Page")
Tags are automatically prefixed with “BUTLER:” creating output like: BUTLER:Editor:Engine
Usage Patterns
// Basic logging (uses DEBUG priority by default)
log(tag) { "Opening file: $filePath" }
// Informational logging
log(tag, INFO) { "Successfully initialized with file: $filePath" }
// Warning logging
log(tag, WARN) { "Cannot insert text - no resources available" }
// Error logging with exception details
try {
// operation
} catch (e: Exception) {
log(tag, ERROR) { "Failed to save file - ${e.asLog()}" }
}
Best Practices
- Always use lazy evaluation with lambda:
{ "message" }for performance - Use
e.asLog()extension for exception logging to get full stack traces - Use appropriate priority levels: ERROR for exceptions, WARN for concerning conditions, INFO for milestones, DEBUG for general logging
- Follow hierarchical tag naming for consistent categorization
- ViewModels should include workspace ID in tags when applicable
- Keep log messages concise but descriptive