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.

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 just Workspace.Arguments)
  • Override callerWorkspaceId property 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 ArgumentsForResult implementation

Example Use Cases

  1. File/Folder Picker (Implemented)
    • Searcher launches Explorer picker to select search directory
    • Full Explorer features: navigation, permissions, folder creation
    • Returns selected path, closes automatically
  2. File Picker for Editor (Future)
    • Editor launches Explorer picker to open files
    • Multi-select support for opening multiple files
  3. 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.ResultEvent interface
  • Use specific event types for different result payloads (e.g., PickerResult)
  • Include both workspaceId and callerWorkspaceId for 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 ResultEvent for consistent handling

Coding Standards

  • Package by feature, not by layer.
  • All user facing strings should be extract to values/strings.xml and 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 @Preview2 functions 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: SearcherWorkspacePage should have previews showing different UI states
    • Place compose previews below the composable being previewed
    • Preview function naming: ComponentNamePreview() and mark as private
  • 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 if that is not single-line, always use brackets.
  • Always add trailing commas.
  • In @Composable functions, the parameter modifier: 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() not searcherSettings.defaultSearchPath.flow.first()
    • For setting values use: searcherSettings.someSetting.value(newValue)
  • 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 FlowCombineExtensions instead of nesting multiple combine statements.
  • Prefer Kotlin standard library types over Java equivalents:
    • Use kotlin.Uuid instead of java.util.UUID
    • Use kotlin.time.Instant instead of java.time.Instant
    • Use kotlin.time.Duration instead of java.time.Duration
    • Use Kotlin collections and their extension functions
  • Check if @OptIn annotations are actually necessary before adding them:
    • Many experimental APIs (like ExperimentalMaterial3Api) are already enabled project-wide via gradle compile flags (freeCompilerArgs)
    • Only add @OptIn if you get a compilation error without it

Dependency Injection

  • Hilt/Dagger throughout the application.
  • @AndroidEntryPoint for Activities/Fragments.
  • @HiltViewModel for 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.twotone package where possible.
  • When creating compose previews, use the @Preview2 annotation, and wrap the UI element in a PreviewWrapper.

Localization

  • All user-facing texts need to be extracted to a strings.xml resources 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 use CAString to 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.xml file that belongs to respective feature module.
  • General texts that are used through-out multiple modules should be placed in the strings.xml file of the app-common module.
  • Before creating a new entry, check if strings.xml file in the app-common module already contains a general version.
  • String IDs should be prefixed with their respective module name. Re-used strings should be prefixed with general or common.
  • Where possible string IDs should not contain implementation details.
    • Postfix with _action instead of prefixing with button_.
    • Instead of module_screen_button_open it should be module_screen_open_action

MVVM with Custom ViewModel Hierarchy

  • ViewModel1ViewModel2ViewModel3ViewModel4.
  • ViewModel4 adds navigation capabilities.
  • Uses Hilt for assisted injection.

Business Logic

General

  • Abstract path system (APath, RawPath).
    • APath offers path segment infos via segments. 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 the app-common module 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

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