RFC-039: Bug Fix & UI Polish Pass
RFC-039: Bug Fix & UI Polish Pass
Document Information
- RFC ID: RFC-039
- Related PRD: FEAT-039 (Bug Fix & UI Polish)
- Created: 2026-03-01
- Last Updated: 2026-03-01
- Status: Completed
- Author: TBD
Overview
Background
After the initial implementation of features through RFC-038, user testing revealed 15 issues ranging from critical bugs (Google OAuth failure, file browser not working) to UX gaps (no attachment button, no tool parameter display). This RFC documents the technical changes made to address all 15 issues in a single coordinated pass.
Goals
- Fix 8 functional bugs across chat, auth, file browser, search, scheduling, and tools
- Improve UX in 7 areas with better visual feedback, controls, and navigation affordances
- Maintain all existing tests passing, update tests affected by behavior changes
- Zero database schema changes – all fixes are code-level only
Non-Goals
- New feature development beyond fixing reported issues
- Database migrations
- New external dependencies
- Full ActivityResultLauncher wiring for attachment pickers (placeholder only)
Technical Design
Changed Files Overview
app/
├── build.gradle.kts # MODIFIED (test config)
├── src/main/
│ ├── AndroidManifest.xml # MODIFIED (network security)
│ ├── res/xml/
│ │ └── network_security_config.xml # NEW
│ └── kotlin/com/oneclaw/shadow/
│ ├── data/
│ │ ├── security/
│ │ │ └── GoogleAuthManager.kt # MODIFIED (6 sub-fixes)
│ │ └── storage/
│ │ └── UserFileStorage.kt # MODIFIED (rootDir)
│ ├── feature/
│ │ ├── agent/
│ │ │ ├── AgentDetailScreen.kt # MODIFIED (behavior section)
│ │ │ ├── AgentDetailViewModel.kt # MODIFIED (save logic)
│ │ │ ├── AgentUiState.kt # MODIFIED (hasRuntimeChanges)
│ │ │ └── usecase/
│ │ │ └── CreateAgentUseCase.kt # MODIFIED (new params)
│ │ ├── bridge/
│ │ │ └── BridgeSettingsScreen.kt # MODIFIED (cards, text)
│ │ ├── chat/
│ │ │ ├── ChatScreen.kt # MODIFIED (attachment, tool card)
│ │ │ └── ChatViewModel.kt # MODIFIED (flush condition)
│ │ ├── schedule/
│ │ │ ├── ScheduledTaskEditViewModel.kt # MODIFIED (alarm check)
│ │ │ └── ScheduledTaskListScreen.kt # MODIFIED (clickable, icon)
│ │ ├── search/usecase/
│ │ │ └── SearchHistoryUseCase.kt # MODIFIED (5s buffer, logs)
│ │ ├── settings/
│ │ │ ├── GoogleAuthScreen.kt # REWRITTEN
│ │ │ └── GoogleAuthViewModel.kt # MODIFIED (edit mode)
│ │ └── tool/
│ │ ├── ToolManagementScreen.kt # MODIFIED (categories)
│ │ └── ToolManagementViewModel.kt # MODIFIED (categorize, refresh)
│ └── tool/engine/
│ └── ToolRegistry.kt # MODIFIED (version flow)
└── src/test/kotlin/com/oneclaw/shadow/feature/
├── schedule/
│ └── ScheduledTaskEditViewModelTest.kt # MODIFIED
└── search/usecase/
└── SearchHistoryUseCaseTest.kt # MODIFIED
Detailed Design
Fix 1: Daily Log Not Flushed on Session Switch
File: ChatViewModel.kt:78
Root Cause: The session switch condition if (previousSessionId != null && sessionId != null && ...) required the new sessionId to be non-null. When switching to “New Conversation” (sessionId = null), the flush was skipped.
Fix: Remove the sessionId != null check.
// Before
if (previousSessionId != null && sessionId != null && previousSessionId != sessionId) {
// After
if (previousSessionId != null && previousSessionId != sessionId) {
Impact: memoryTriggerManager?.onSessionSwitch(previousSessionId) is now called whenever leaving a session, regardless of whether the destination is a named session or a new conversation.
Fix 2: Wake Lock Explanation Text
File: BridgeSettingsScreen.kt
Change: Added a Text composable below the Wake Lock switch explaining its purpose and battery impact.
Text(
text = "Keeps the bridge service alive when the screen is off. " +
"Required for reliable message delivery, but increases battery usage.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 4.dp)
)
Fix 3: Google OAuth Flow (6 Sub-Fixes)
Files: GoogleAuthManager.kt, AndroidManifest.xml, network_security_config.xml (new)
3A: Network Security Config
- Created
res/xml/network_security_config.xmlallowing cleartext traffic to127.0.0.1(needed for the loopback OAuth redirect). - Referenced in
AndroidManifest.xmlviaandroid:networkSecurityConfig.
3B: Loopback Server Binding
// Before
val serverSocket = ServerSocket(0)
// After
val serverSocket = withContext(Dispatchers.IO) {
ServerSocket(0, 1, InetAddress.getByName("127.0.0.1"))
}
Explicit 127.0.0.1 binding matches the oneclaw-1 reference implementation and prevents binding to 0.0.0.0.
3C: Token Exchange Retry
Added retry loop (3 attempts, 3-second delay) around exchangeCodeForTokens() + fetchUserEmail(). The first attempt often fails because the app is still backgrounded when the browser redirects back, and Android may restrict network access.
repeat(3) { attempt ->
if (attempt > 0) {
Log.d(TAG, "Token exchange retry $attempt after: ${lastError?.message}")
delay(3000)
}
try {
val tokens = exchangeCodeForTokens(...)
val email = fetchUserEmail(tokens.accessToken)
// save and return success
} catch (e: UnknownHostException) { lastError = e }
catch (e: SocketTimeoutException) { lastError = e }
catch (e: Exception) { lastError = e }
}
3D: NonCancellable Wrapping
Wrapped the critical auth code receive + token exchange flow with withContext(NonCancellable) to prevent Activity recreation from cancelling the coroutine mid-flow.
3E: Browser Intent Fix
- Removed
FLAG_ACTIVITY_NEW_TASKfrom the browser intent. - Launches browser on
Dispatchers.Mainto avoid context issues.
3F: Specific Error Messages
val errorMsg = when (lastError) {
is UnknownHostException -> "Network unavailable. Check your internet connection and try again."
is SocketTimeoutException -> "Connection timed out. Check your internet connection and try again."
else -> lastError?.message ?: "Authorization failed"
}
Additional Refactoring
waitForAuthCode()now returnsString?(nullable) instead of throwing on failure.- Extracted
parseAuthCode()method for cleaner URL parsing. - Response check
response.isSuccessfuladded before parsing token JSON. Jsoninstance reused as a class-levelval json = Json { ignoreUnknownKeys = true }.- ServerSocket is closed in a
finallyblock withNonCancellable.
Fix 4: Google Auth Screen Rewrite
Files: GoogleAuthScreen.kt, GoogleAuthViewModel.kt
New UI Components
-
StatusCard – Shows “Connected: email@example.com” or “Not connected” with description of what Google Workspace plugins provide.
-
PermissionsCard – Lists all requested OAuth scopes (Gmail, Calendar, Tasks, Contacts, Drive, Docs, Sheets, Slides, Forms) with descriptions.
-
Unverified App Warning Card – Explains the “Google hasn’t verified this app” warning and how to proceed safely.
- SetupInstructions – 9-step numbered guide for GCP project setup:
- Go to console.cloud.google.com (clickable link)
- Enable 9 specific APIs
- Configure OAuth consent screen
- Set branding, publish app
- Add 11 OAuth scopes
- Create Desktop OAuth client
- Copy Client ID and Secret
- CredentialsSection – Three-state display:
- Signed in: Shows connected status, “Disconnect” button, “Change OAuth Credentials” button
- Has credentials, not signed in: Shows “Authorize with Google” button with loading state, retry hint, “Change OAuth Credentials” button
- No credentials / editing: Shows input fields with password visibility toggle, Save/Cancel buttons
- Error display – Uses
errorContainerCard instead of plain error-colored text.
ViewModel Changes
- Added
editingCredentials: Booleanto track credential editing mode - Added
dirty: Booleanto track unsaved changes to credential fields - Added
startEditingCredentials(),cancelEditingCredentials(),clearError()methods onClientIdChanged/onClientSecretChangednow setdirty = truesaveCredentials()resetseditingCredentialsanddirty
Fix 5: Built-in Agent Runtime Settings
Files: AgentDetailScreen.kt, AgentDetailViewModel.kt, AgentUiState.kt
Problem: Built-in agents had Web Search toggle disabled (enabled = !uiState.isBuiltIn), and the Save button was hidden for built-in agents.
Fix:
- Removed
enabled = !uiState.isBuiltInfrom Web Search switch and its parent Row’sclickable. - Added
hasRuntimeChangescomputed property toAgentDetailUiState:val hasRuntimeChanges: Boolean get() = webSearchEnabled != savedWebSearchEnabled || temperature != savedTemperature || maxIterations != savedMaxIterations - Save button shown when
!uiState.isBuiltIn || uiState.hasRuntimeChanges. hasUnsavedChangesfor existing agents now delegates tohasRuntimeChangesfor the runtime fields.
Save logic change: When saving a built-in agent, the ViewModel preserves immutable fields from originalAgent:
val updated = Agent(
id = state.agentId!!,
name = if (state.isBuiltIn) orig.name else state.name.trim(),
description = if (state.isBuiltIn) orig.description else ...,
systemPrompt = if (state.isBuiltIn) orig.systemPrompt else ...,
// Runtime fields always from state
temperature = state.temperature,
maxIterations = state.maxIterations,
webSearchEnabled = state.webSearchEnabled,
isBuiltIn = orig.isBuiltIn,
...
)
Fix 6: Temperature & Max Iterations UI
Files: AgentDetailScreen.kt, CreateAgentUseCase.kt
Added a “BEHAVIOR” section below Web Search in the agent detail screen:
- Temperature Slider: Range 0.0-2.0, 20 steps, displays current value. Description: “Lower values produce more focused output; higher values are more creative.”
- Max Iterations TextField:
OutlinedTextFieldwith number keyboard, validation 1-100, supporting text showing range or error.
CreateAgentUseCase updated to accept webSearchEnabled, temperature, maxIterations parameters and pass them to the Agent constructor.
Fix 7: Attachment Button & Picker Integration
File: ChatScreen.kt
- Added
onAttachmentClickandhasPendingAttachmentsparameters toChatInput. - Added attachment button (AttachFile icon) in a circular container next to the skill button.
- When
uiState.pendingAttachmentsis not empty,AttachmentPreviewRowis displayed above the input. - When
showAttachmentPickeris true,AttachmentPickerSheetis displayed. - Send button condition changed from
text.isNotBlank() && hasConfiguredProviderto(text.isNotBlank() || hasPendingAttachments) && hasConfiguredProvider.
Note: ActivityResultLauncher callbacks in AttachmentPickerSheet are placeholder ({ /* Caller wires externally */ }).
Fix 8: Tool Call Parameter Display
File: ChatScreen.kt (ToolCallCard composable)
Rewrote ToolCallCard from a simple Row to a Column with expand/collapse:
var expanded by remember { mutableStateOf(false) }
val hasInput = !toolInput.isNullOrBlank()
- Card is clickable only when
hasInputis true. - Collapsed state shows tool name with expand/collapse icon.
- Expanded state shows
toolInputin monospacebodySmallfont, truncated to 20 lines withTextOverflow.Ellipsis.
Fix 9: ToolRegistry Version Flow
File: ToolRegistry.kt
Added a StateFlow<Int> version counter that increments on every structural change:
@PublishedApi
internal val _version = MutableStateFlow(0)
val version: StateFlow<Int> = _version.asStateFlow()
Incremented in register(), unregister(), and unregisterByType() (only when keys were actually removed).
_version is annotated @PublishedApi internal because unregisterByType() is an inline reified function that needs access.
Fix 10: Built-in Tool Category Grouping
Files: ToolManagementViewModel.kt, ToolManagementScreen.kt
Data Model
data class BuiltInCategoryUiItem(
val category: String,
val tools: List<ToolUiItem>,
val isExpanded: Boolean = false
)
Categorization Logic
private fun categorizeBuiltInTools(tools: List<ToolUiItem>): List<BuiltInCategoryUiItem> {
val categorized = tools.groupBy { tool ->
val name = tool.name.lowercase()
when {
name.startsWith("calendar") -> "Calendar"
name.startsWith("config") -> "Config"
name.startsWith("provider") || name.startsWith("model") -> "Provider / Model"
name.startsWith("agent") -> "Agent"
name.startsWith("schedule") || name.startsWith("scheduled") -> "Scheduling"
name.startsWith("file") || name.startsWith("http") || name.startsWith("web") -> "Files & Web"
name.startsWith("pdf") -> "PDF"
name.startsWith("js") -> "JS Tools"
else -> "Other"
}
}
// Ordered display
val categoryOrder = listOf(
"Calendar", "Config", "Provider / Model", "Agent",
"Scheduling", "Files & Web", "PDF", "JS Tools", "Other"
)
// ...
}
UI
BuiltInCategoryHeadercomposable with expand/collapse chevron and tool count.toggleBuiltInCategoryExpanded()method to toggle categories.- Expanded state preserved across reloads via existing state matching.
Auto-Refresh
init {
loadTools()
viewModelScope.launch {
toolRegistry.version.drop(1).collect { loadTools() }
}
}
Fix 11: Bridge Settings Card Wrapping
File: BridgeSettingsScreen.kt
ChannelSectioncomposable now wraps content inSurface(shape = RoundedCornerShape(16.dp), color = surfaceVariant).- All
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))replaced withSpacer(modifier = Modifier.height(12.dp)).
Fix 12: File Browser Root Directory
File: UserFileStorage.kt
// Before
open val rootDir: File
get() = File(context.filesDir, "user_files").also { it.mkdirs() }
// After
open val rootDir: File
get() = context.filesDir
The user_files/ subdirectory did not exist and was always empty. Changing to context.filesDir exposes all internal files (memory/, daily_logs/, databases, etc.) to the file browser.
Fix 13: Search History Current Message Exclusion
File: SearchHistoryUseCase.kt
Root Cause: When the AI calls search_history, the user’s message is already saved to the database. With dateTo = null, the search used Long.MAX_VALUE as the upper bound, including the current message in results.
Fix: Added a 5-second buffer constant and apply it when dateTo is null:
companion object {
private const val RECENT_MESSAGE_BUFFER_MS = 5_000L
}
val createdBefore = if (dateTo != null) dateTo
else System.currentTimeMillis() - RECENT_MESSAGE_BUFFER_MS
Added Log.d debug logging throughout the search pipeline for production debugging.
Fix 14: Scheduled Task List Clickability
File: ScheduledTaskListScreen.kt
- Added
clickable(onClick = onEdit)modifier toScheduledTaskItem’s Row. - Added
ChevronRighticon (20.dp) after the Switch as a visual affordance. - Added required imports:
clickable,ChevronRight,size.
Fix 15: Exact Alarm Permission for Edited Tasks
File: ScheduledTaskEditViewModel.kt
// Before (skipped check for edits)
if (!state.isEditing && !exactAlarmHelper.canScheduleExactAlarms()) {
// After (checks for both new and edited tasks)
if (!exactAlarmHelper.canScheduleExactAlarms()) {
Test Changes
SearchHistoryUseCaseTest
Updated coVerify assertions that previously expected Long.MAX_VALUE as createdBefore to use any() matcher, since the actual value is now System.currentTimeMillis() - 5000 (a dynamic value).
// Before
coVerify { messageDao.searchContent("test", 0L, Long.MAX_VALUE, 50) }
// After
coVerify { messageDao.searchContent("test", 0L, any(), 50) }
ScheduledTaskEditViewModelTest
Updated the test save does not show dialog when editing existing task to reflect new behavior:
// Before: asserted dialog NOT shown, save succeeded
assertFalse(state.showExactAlarmDialog)
assertTrue(state.savedSuccessfully)
coVerify(exactly = 1) { updateUseCase(any()) }
// After: asserted dialog IS shown, save blocked
assertTrue(state.showExactAlarmDialog)
assertFalse(state.savedSuccessfully)
coVerify(exactly = 0) { updateUseCase(any()) }
Build Config
Added isReturnDefaultValues = true to app/build.gradle.kts under testOptions.unitTests to prevent android.util.Log.d() calls in SearchHistoryUseCase from throwing RuntimeException in JVM unit tests.
Compilation & Test Results
- First compilation: 2 errors found and fixed
- Missing
import androidx.compose.foundation.layout.sizeinScheduledTaskListScreen.kt _versioninToolRegistrywasprivatebut accessed from inline reified functionunregisterByType()– changed to@PublishedApi internal
- Missing
- After fixes:
./gradlew compileDebugUnitTestKotlinpassed - After test fixes:
./gradlew testpassed (all tests green)
Security Considerations
- network_security_config.xml: Only allows cleartext traffic to
127.0.0.1for the OAuth loopback redirect. All other hosts still require HTTPS. - OAuth tokens: Continue to use
EncryptedSharedPreferences. No changes to token storage security. - File browser root change: Exposing
context.filesDirinstead ofuser_files/gives visibility to all app-internal files. This is intentional – the file browser is a user-facing debug/management tool within the app itself, not accessible externally.
Performance Considerations
ToolRegistry.versionStateFlow adds negligible overhead (atomic int increment).categorizeBuiltInTools()runs in O(n) where n is the number of built-in tools (currently ~30).SearchHistoryUseCase5-second buffer has no performance impact.- OAuth retry loop adds up to 6 seconds of delay only on failure paths.
Round 2: Additional Fixes (Issues 16-25)
Fix 16: Bridge Settings – Collapsible Channel Headers
File: BridgeSettingsScreen.kt
Refactored ChannelSection to be a single-line collapsible header:
- Channel name on the left, enable Switch on the right
- Removed redundant “Enable X” label (the title already says the channel name)
- When enabled (switch ON): expand to show configuration fields + setup guide
- When disabled (switch OFF): collapse to single header line
- Uses
AnimatedVisibilityfor smooth expand/collapse animation
Fix 17: Bridge Settings – Setup Instructions per Channel
File: BridgeSettingsScreen.kt
Added SetupGuide composable inside each expanded channel section:
- Clickable “Setup guide” text that toggles numbered step list
- Channel-specific instructions:
- Telegram: @BotFather, /newbot, @userinfobot
- Discord: Developer Portal, Bot token, Message Content Intent, OAuth2, Developer Mode
- Slack: api.slack.com/apps, OAuth scopes, App-Level Token, Socket Mode, Event Subscriptions
- Matrix: Register bot account, Element access token, invite to rooms
- LINE: developers.line.biz, provider/channel, Channel Access Token, Channel Secret
- Web Chat: Port selection, optional access token, browser access URL
Fix 18: Wake Lock Warning Styling
File: BridgeSettingsScreen.kt
Changed wake lock explanation from plain onSurfaceVariant text to:
Rowlayout withIcons.Default.Warningicon + text- Both icon and text use
MaterialTheme.colorScheme.errorcolor
Fix 19: Google OAuth Screen Crash
File: GoogleAuthScreen.kt
Root Cause: pushLink(LinkAnnotation.Url("https://console.cloud.google.com")) crashes on Compose BOM 2024.12.01 because the LinkAnnotation API is not fully stable.
Fix: Replaced with stable pushStringAnnotation + ClickableText pattern:
pushStringAnnotation(tag = "URL", annotation = consoleUrl)
// ...
ClickableText(
text = step1Text,
onClick = { offset ->
step1Text.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { ... context.startActivity(Intent(ACTION_VIEW, Uri.parse(it.item))) }
}
)
Also fixed var stepNumber = 1 side-effect during composition by passing step numbers explicitly as parameters.
Fix 20: Delete Credentials Button
Files: GoogleAuthManager.kt, GoogleAuthViewModel.kt, GoogleAuthScreen.kt
GoogleAuthManager.clearAllCredentials(): Clears both OAuth client credentials (client ID + secret) and all tokens/email from EncryptedSharedPreferences.GoogleAuthViewModel.deleteCredentials(): CallsclearAllCredentials()and resets UiState to initial empty state.GoogleAuthScreen: AddedOutlinedButtonwith error color for “Delete Credentials” in both signed-in and has-credentials states.
Fix 21: Agent Max Iterations – Slider
Files: AgentDetailScreen.kt, AgentUiState.kt, AgentDetailViewModel.kt, SendMessageUseCase.kt
- Replaced
OutlinedTextFieldwithSlider(range 1-200, steps = 198) - At 200, displays “Unlimited” and stores as
null - When
maxIterationsisnull, slider shows at position 25 (sensible default) - Removed
maxIterationsErrorfromAgentUiState(slider enforces valid range) - Removed validation logic from
AgentDetailViewModel.updateMaxIterations() - Removed
maxIterationsErrorcheck fromsaveAgent() SendMessageUseCase: Changedagent.maxIterations ?: MAX_TOOL_ROUNDStoagent.maxIterations ?: Int.MAX_VALUE(null = truly unlimited)
Fix 22: Attachment Picker – Wire ActivityResultLauncher
File: ChatScreen.kt, file_paths.xml
Registered 4 rememberLauncherForActivityResult instances in ChatScreen:
- Photo:
ActivityResultContracts.PickVisualMedia()withImageOnly->viewModel.addAttachment(uri) - Video:
ActivityResultContracts.PickVisualMedia()withVideoOnly->viewModel.addAttachment(uri) - Camera:
ActivityResultContracts.TakePicture()-> creates temp file incache/camera_photos/, gets URI viaFileProvider->viewModel.addCameraPhoto(file) - File:
ActivityResultContracts.GetContent()with"*/*"->viewModel.addAttachment(uri)
Added <cache-path name="camera_photos" path="camera_photos/" /> to file_paths.xml for camera photo temp files.
Fix 23: Attachment Picker – Dark Mode Status Bar
File: AttachmentPickerSheet.kt
Set explicit scrim color on ModalBottomSheet:
ModalBottomSheet(
onDismissRequest = onDismiss,
scrimColor = Color.Black.copy(alpha = 0.32f)
)
This prevents the default scrim from interfering with status bar icon colors in dark mode.
Fix 24: Tool Management – Google Product Names
File: ToolManagementViewModel.kt
Updated categorizeBuiltInTools() mapping to use full Google product names:
"calendar"->"Google Calendar""gmail"->"Gmail""drive"->"Google Drive""docs"/"document"->"Google Docs""sheets"/"spreadsheet"->"Google Sheets""slides"/"presentation"->"Google Slides""forms"->"Google Forms""contacts"/"people"->"Google Contacts""tasks"->"Google Tasks"
Updated categoryOrder to include all Google product categories in logical order.
Fix 25: Tool Management – Badge Layout Fix
File: ToolManagementScreen.kt
Restructured ToolListItem layout:
- Tool name on its own line with
maxLines = 1andTextOverflow.Ellipsis SourceBadgemoved to second line (same row as description)- Badge no longer competes with tool name for horizontal space
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-03-01 | 1.0 | Initial version (15 issues fixed) | - |
| 2026-03-01 | 2.0 | Round 2 (10 additional issues: 16-25) | - |