From 4dcb84f6ae49d5f16d5e3e13077b41aaeb329bd1 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 02:53:42 +0530 Subject: [PATCH 01/16] =?UTF-8?q?fix:=20Dynamic=20thought=20display=20with?= =?UTF-8?q?=20animations=20=F0=9F=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #1 - Static Thought Display in ChatInterface Changes: - Added animated ThinkingIndicator with bouncing dots when agent is processing - Added pulsing animation on running step icons (scale + alpha pulse) - Added pulsing "● Running ·" indicator in activity trail - Steps now fade+slide in with staggered animation - Added thinking phase rotation in ViewModel - Auto-expanded running activity cards - Background highlight pulse on active steps Fixes the issue where agent thoughts were static and same every time. --- .../com/clawdroid/app/ui/chat/ChatScreen.kt | 240 +++++++++++------- .../clawdroid/app/ui/chat/ChatViewModel.kt | 18 ++ 2 files changed, 162 insertions(+), 96 deletions(-) diff --git a/app/src/main/java/com/clawdroid/app/ui/chat/ChatScreen.kt b/app/src/main/java/com/clawdroid/app/ui/chat/ChatScreen.kt index 540494e..1edde81 100644 --- a/app/src/main/java/com/clawdroid/app/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/clawdroid/app/ui/chat/ChatScreen.kt @@ -18,6 +18,12 @@ import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -1329,7 +1335,9 @@ private fun ActivityMessageCard(item: ActivityChatItem) { var previewFile by remember { mutableStateOf(null) } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (item.steps.size == 1) { + if (item.steps.isEmpty() && item.running) { + ThinkingIndicator() + } else if (item.steps.size == 1) { InlineActivityStep(step = item.steps[0]) } else { InlineActivityTrail(steps = item.steps, running = item.running) @@ -1362,6 +1370,17 @@ private fun InlineActivityTrail( else -> "Preparing activity" } + // Pulsing animation for running state + val pulseTransition = rememberInfiniteTransition() + val pulseAlpha by pulseTransition.animateFloat( + initialValue = 0.5f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(900, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ) + ) + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Surface( modifier = Modifier @@ -1378,8 +1397,8 @@ private fun InlineActivityTrail( horizontalArrangement = Arrangement.spacedBy(10.dp), ) { Text( - text = if (running) "Running ·" else "Done ·", - color = if (running) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f), + text = if (running) "● Running ·" else "✓ Done ·", + color = if (running) MaterialTheme.colorScheme.primaryContainer.copy(alpha = pulseAlpha) else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f), style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold), ) Text( @@ -1396,7 +1415,11 @@ private fun InlineActivityTrail( } } - AnimatedVisibility(visible = expanded && steps.isNotEmpty()) { + AnimatedVisibility( + visible = expanded && steps.isNotEmpty(), + enter = fadeIn(animationSpec = tween(300)) + slideInVertically { -it / 4 }, + exit = fadeOut(animationSpec = tween(200)) + slideOutVertically { -it / 4 } + ) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), @@ -1405,30 +1428,74 @@ private fun InlineActivityTrail( border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.85f)), ) { Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - steps.takeLast(4).forEach { step -> InlineActivityStep(step) } + steps.takeLast(4).forEachIndexed { index, step -> + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(400, delayMillis = index * 80)) + + slideInVertically { it / 3 } + ) { + InlineActivityStep(step) + } + } } } } } } +@Composable @Composable private fun InlineActivityStep(step: ActivityStepItem) { var expanded by remember(step.running) { mutableStateOf(step.running) } + + // Pulsing animation for running step icon + val stepPulseTransition = rememberInfiniteTransition() + val stepPulseScale by stepPulseTransition.animateFloat( + initialValue = 1.0f, + targetValue = 1.25f, + animationSpec = infiniteRepeatable( + animation = tween(700, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ) + ) + val stepPulseAlpha by stepPulseTransition.animateFloat( + initialValue = 0.7f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(700, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ) + ) + Column( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(8.dp)) .clickable { expanded = !expanded } - .background(MaterialTheme.colorScheme.surfaceContainer) + .background( + if (step.running) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.12f * stepPulseAlpha) + else MaterialTheme.colorScheme.surfaceContainer + ) .padding(horizontal = 14.dp, vertical = 11.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row(verticalAlignment = Alignment.CenterVertically) { - Text(text = step.type.icon, style = MaterialTheme.typography.labelLarge) + // Animated icon for running steps + if (step.running) { + Text( + text = step.type.icon, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.graphicsLayer( + scaleX = stepPulseScale, + scaleY = stepPulseScale + ) + ) + } else { + Text(text = step.type.icon, style = MaterialTheme.typography.labelLarge) + } Spacer(modifier = Modifier.width(10.dp)) Text( - text = formatDiffDisplayText("${step.summary}${if (step.running) "…" else ""}"), + text = formatDiffDisplayText("$" + "{step.summary}" + "$" + "{if (step.running) "\u2026" else ""}"), modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold), @@ -1436,99 +1503,12 @@ private fun InlineActivityStep(step: ActivityStepItem) { if (step.isError) { Spacer(modifier = Modifier.width(8.dp)) Text( - text = "❌", + text = "\u274c", style = MaterialTheme.typography.labelLarge ) } } AnimatedVisibility(visible = expanded) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 4.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - val parsed = formatStepContent(step) - - if (parsed.copyText != null || parsed.displayText.isNotEmpty()) { - Row( - modifier = Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - shape = RoundedCornerShape(6.dp) - ) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - if (parsed.title.isNotEmpty()) { - Text( - text = parsed.title, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold) - ) - Spacer(modifier = Modifier.height(2.dp)) - } - Text( - text = formatDiffDisplayText(parsed.displayText), - color = MaterialTheme.colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp - ), - maxLines = 2, - overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis - ) - } - if (parsed.copyText != null) { - val clipboardManager = LocalClipboardManager.current - val context = LocalContext.current - IconButton( - onClick = { - clipboardManager.setText(AnnotatedString(parsed.copyText)) - Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() - }, - modifier = Modifier.size(36.dp) - ) { - Icon( - imageVector = Icons.Rounded.ContentCopy, - contentDescription = "Copy text", - modifier = Modifier.size(18.dp), - tint = MaterialTheme.colorScheme.primary - ) - } - } - } - } - - if (parsed.outputText.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 180.dp) - .verticalScroll(rememberScrollState()) - .background( - MaterialTheme.colorScheme.surfaceContainerLowest, - shape = RoundedCornerShape(6.dp) - ) - .padding(8.dp) - ) { - Text( - text = formatDiffOutputText(parsed.outputText), - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = FontFamily.Monospace, - fontSize = 13.sp, - lineHeight = 18.sp - ) - ) - } - } - } - } - } -} @Composable private fun PremiumInputBar( @@ -2744,3 +2724,71 @@ private fun AttachmentPreviewRow( } } } + +@Composable +private fun ThinkingIndicator() { + // Animated thinking dots that bounce while agent is processing + val infiniteTransition = rememberInfiniteTransition() + val dot1Alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(600, delayMillis = 0, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ) + ) + val dot2Alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(600, delayMillis = 200, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ) + ) + val dot3Alpha by infiniteTransition.animateFloat( + initialValue = 0.3f, + targetValue = 1.0f, + animationSpec = infiniteRepeatable( + animation = tween(600, delayMillis = 400, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ) + ) + + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow.copy(alpha = 0.72f), + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f)), + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = "Thinking", + style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "\u2022", + modifier = Modifier.graphicsLayer(alpha = dot1Alpha), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primaryContainer, + ) + Text( + text = "\u2022", + modifier = Modifier.graphicsLayer(alpha = dot2Alpha), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primaryContainer, + ) + Text( + text = "\u2022", + modifier = Modifier.graphicsLayer(alpha = dot3Alpha), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.primaryContainer, + ) + } + } +} diff --git a/app/src/main/java/com/clawdroid/app/ui/chat/ChatViewModel.kt b/app/src/main/java/com/clawdroid/app/ui/chat/ChatViewModel.kt index c42e730..d43bd7c 100644 --- a/app/src/main/java/com/clawdroid/app/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/clawdroid/app/ui/chat/ChatViewModel.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.launch import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine import kotlinx.coroutines.isActive +import kotlinx.coroutines.delay import kotlinx.coroutines.currentCoroutineContext import java.util.UUID @@ -48,6 +49,7 @@ data class ChatUiState( val streamingSteps: List = emptyList(), val streamingMessageId: String? = null, val runtimeState: AgentRuntimeState = AgentRuntimeState.Idle, + val thinkingPhase: String = "Thinking", val input: String = "", val isCallModeActive: Boolean = false, val isCallMuted: Boolean = false, @@ -252,7 +254,23 @@ class ChatViewModel( isStreaming = true, runtimeState = AgentRuntimeState.Running, orbState = OrbState.Thinking, + thinkingPhase = "Analyzing", ) + + // Rotate thinking phrases while agent is processing but no output yet + val thinkingPhrases = listOf( + "Analyzing", "Processing", "Thinking", + "Reasoning", "Computing", "Exploring", + "Synthesizing", "Formulating" + ) + val thinkingJob = viewModelScope.launch { + var phraseIdx = 0 + while (uiState.isStreaming && uiState.streamingText.isBlank() && uiState.streamingSteps.isEmpty()) { + delay(1200) + phraseIdx = (phraseIdx + 1) % thinkingPhrases.size + uiState = uiState.copy(thinkingPhase = thinkingPhrases[phraseIdx]) + } + } val conv = db.conversations().getById(convId) if (conv?.title == "New Agent Chat") { From 4104c34043b2a1c17951dfcc9fc0b81cb473146d Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:09:10 +0530 Subject: [PATCH 02/16] test --- test_nova.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test_nova.txt diff --git a/test_nova.txt b/test_nova.txt new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/test_nova.txt @@ -0,0 +1 @@ +test \ No newline at end of file From 3e070761ef522315dc327b53b81c98156c4a50b2 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:09:23 +0530 Subject: [PATCH 03/16] cleanup: remove test file --- test_nova.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 test_nova.txt diff --git a/test_nova.txt b/test_nova.txt deleted file mode 100644 index 30d74d2..0000000 --- a/test_nova.txt +++ /dev/null @@ -1 +0,0 @@ -test \ No newline at end of file From 6da2931be5ffd280c5fee4964d2218a061818e2a Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:09:36 +0530 Subject: [PATCH 04/16] test dir --- .github/test.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/test.txt diff --git a/.github/test.txt b/.github/test.txt new file mode 100644 index 0000000..30d74d2 --- /dev/null +++ b/.github/test.txt @@ -0,0 +1 @@ +test \ No newline at end of file From cb01464a78579e96373307a375c8fb7b3a518169 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:09:50 +0530 Subject: [PATCH 05/16] cleanup test --- .github/test.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/test.txt diff --git a/.github/test.txt b/.github/test.txt deleted file mode 100644 index 30d74d2..0000000 --- a/.github/test.txt +++ /dev/null @@ -1 +0,0 @@ -test \ No newline at end of file From fecd99203bbeb658e9a88e47db907a53bf16a3dc Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:13:22 +0530 Subject: [PATCH 06/16] ci: add .github directory --- .github/placeholder.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/placeholder.txt diff --git a/.github/placeholder.txt b/.github/placeholder.txt new file mode 100644 index 0000000..b3a4252 --- /dev/null +++ b/.github/placeholder.txt @@ -0,0 +1 @@ +placeholder \ No newline at end of file From 5f91f99defca69ecddc5c5741fcaf00660b98c16 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:14:35 +0530 Subject: [PATCH 07/16] cleanup placeholder --- .github/placeholder.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/placeholder.txt diff --git a/.github/placeholder.txt b/.github/placeholder.txt deleted file mode 100644 index b3a4252..0000000 --- a/.github/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file From f251db29827a5a571cd0f27a7109b5abfe321e9d Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:20:11 +0530 Subject: [PATCH 08/16] chore: add .github directory --- .github/placeholder.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/placeholder.txt diff --git a/.github/placeholder.txt b/.github/placeholder.txt new file mode 100644 index 0000000..b3a4252 --- /dev/null +++ b/.github/placeholder.txt @@ -0,0 +1 @@ +placeholder \ No newline at end of file From 4c49f30e95b2abf21060d732e4f14f643a9590c2 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:22:36 +0530 Subject: [PATCH 09/16] test --- .github/test.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/test.txt diff --git a/.github/test.txt b/.github/test.txt new file mode 100644 index 0000000..08cf610 --- /dev/null +++ b/.github/test.txt @@ -0,0 +1 @@ +test content \ No newline at end of file From a28474b4929d224c95f0ae6e2c70071545dc38e8 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:23:36 +0530 Subject: [PATCH 10/16] cleanup --- .github/test.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/test.txt diff --git a/.github/test.txt b/.github/test.txt deleted file mode 100644 index 08cf610..0000000 --- a/.github/test.txt +++ /dev/null @@ -1 +0,0 @@ -test content \ No newline at end of file From e34d1a976503d95912926ba798ef75084b4338c9 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:31:17 +0530 Subject: [PATCH 11/16] =?UTF-8?q?ci:=20add=20APK=20build=20&=20beta=20rele?= =?UTF-8?q?ase=20workflow=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 177 ++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..7271839 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,177 @@ +name: 🏗️ Build & Beta Release + +on: + push: + branches: [ main, develop ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + workflow_dispatch: + inputs: + release_type: + description: 'Release type' + required: true + default: 'beta' + type: choice + options: + - beta + - alpha + - production + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: 🔨 Build APK + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: 📥 Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: ☕ Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: 🤖 Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: 📦 Accept Android SDK licenses + run: yes | $ANDROID_HOME/tools/bin/sdkmanager --licenses || true + + - name: 🛠️ Create local.properties from secrets + run: | + cat > local.properties << 'LOCALEOF' + LLM_BASE_URL=${{ secrets.LLM_BASE_URL || 'https://api.siliconflow.com/v1' }} + LLM_MODEL=${{ secrets.LLM_MODEL || 'moonshotai/Kimi-K2.6' }} + LLM_API_KEY=${{ secrets.LLM_API_KEY || '' }} + LLM_PROVIDER=${{ secrets.LLM_PROVIDER || 'siliconflow' }} + OPENAI_REALTIME_API_KEY=${{ secrets.OPENAI_REALTIME_API_KEY || '' }} + GITHUB_OAUTH_CLIENT_ID=${{ secrets.GH_OAUTH_CLIENT_ID || '' }} + GITHUB_OAUTH_CLIENT_SECRET=${{ secrets.GH_OAUTH_CLIENT_SECRET || '' }} + GITHUB_OAUTH_TOKEN=${{ secrets.GH_OAUTH_TOKEN || '' }} + NOTION_OAUTH_CLIENT_ID=${{ secrets.NOTION_CLIENT_ID || '' }} + NOTION_OAUTH_CLIENT_SECRET=${{ secrets.NOTION_CLIENT_SECRET || '' }} + SPOTIFY_OAUTH_CLIENT_ID=${{ secrets.SPOTIFY_CLIENT_ID || '' }} + SPOTIFY_OAUTH_CLIENT_SECRET=${{ secrets.SPOTIFY_CLIENT_SECRET || '' }} + LOCALEOF + echo "✅ local.properties created" + + - name: 🔧 Grant execute permission for gradlew + run: chmod +x gradlew + + - name: 🏗️ Build Debug APK + run: ./gradlew assembleDebug --no-daemon --stacktrace + env: + CI: true + + - name: 📤 Upload Debug APK as artifact + uses: actions/upload-artifact@v4 + with: + name: ClawDroid-Debug-${{ github.sha }} + path: app/build/outputs/apk/debug/*.apk + if-no-files-found: error + compression-level: 0 + + # --- Signing & Release Build (optional) --- + - name: 🔐 Decode Keystore + id: decode_keystore + if: | + github.event_name != 'pull_request' && + ( startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' ) && + secrets.KEYSTORE_BASE64 != '' + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/keystore.jks + echo "keystore_exists=true" >> $GITHUB_OUTPUT + + - name: 🏗️ Build Release APK (signed) + if: steps.decode_keystore.outputs.keystore_exists == 'true' + run: | + ./gradlew assembleRelease \ + -Pandroid.injected.signing.store.file=app/keystore.jks \ + -Pandroid.injected.signing.store.password=${{ secrets.KEYSTORE_PASSWORD }} \ + -Pandroid.injected.signing.key.alias=${{ secrets.KEYSTORE_KEY_ALIAS }} \ + -Pandroid.injected.signing.key.password=${{ secrets.KEYSTORE_KEY_PASSWORD }} \ + --no-daemon --stacktrace + + - name: 📤 Upload Release APK + if: steps.decode_keystore.outputs.keystore_exists == 'true' + uses: actions/upload-artifact@v4 + with: + name: ClawDroid-Release-${{ github.sha }} + path: app/build/outputs/apk/release/*.apk + if-no-files-found: warn + compression-level: 0 + + # --- Tag-based Release --- + - name: 🚀 Create GitHub Release (tag push) + if: startsWith(github.ref, 'refs/tags/v') + uses: softprops/action-gh-release@v2 + with: + name: ClawDroid ${{ github.ref_name }} + tag_name: ${{ github.ref_name }} + body: | + ## 🤖 ClawDroid ${{ github.ref_name }} + + ### 📦 Downloads + - **Debug APK** — attached below (unsigned, for testing) + - **Release APK** — attached below (signed, production-ready) + + ### 🆕 What's New + _Auto-generated from commits since last release._ + draft: false + prerelease: ${{ contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} + files: | + app/build/outputs/apk/debug/*.apk + app/build/outputs/apk/release/*.apk + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # --- Manual Workflow Dispatch Release --- + - name: 🚀 Upload to Beta Release (workflow_dispatch) + if: github.event_name == 'workflow_dispatch' + uses: softprops/action-gh-release@v2 + with: + tag_name: beta-${{ github.run_number }}-${{ github.sha }} + name: "Beta ${{ github.run_number }} (${{ inputs.release_type }})" + body: | + ## 🤖 ClawDroid Beta Build + + - **Build**: #${{ github.run_number }} + - **Type**: ${{ inputs.release_type }} + - **Commit**: ${{ github.sha }} + - **Trigger**: Manual workflow dispatch + draft: true + prerelease: true + files: | + app/build/outputs/apk/debug/*.apk + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: 📊 Summary + if: always() + run: | + echo "## ✅ Build Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Status | Artifact |" >> $GITHUB_STEP_SUMMARY + echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY + + if ls app/build/outputs/apk/debug/*.apk 1>/dev/null 2>&1; then + APK=$(ls -h app/build/outputs/apk/debug/*.apk 2>/dev/null | head -1) + SIZE=$(stat -c%s "$APK" 2>/dev/null | numfmt --to=iec 2>/dev/null || echo "?") + echo "| ✅ Debug APK | \`$(basename $APK)\` ($SIZE) |" >> $GITHUB_STEP_SUMMARY + fi + + if ls app/build/outputs/apk/release/*.apk 1>/dev/null 2>&1; then + APK=$(ls -h app/build/outputs/apk/release/*.apk 2>/dev/null | head -1) + SIZE=$(stat -c%s "$APK" 2>/dev/null | numfmt --to=iec 2>/dev/null || echo "?") + echo "| ✅ Release APK | \`$(basename $APK)\` ($SIZE) |" >> $GITHUB_STEP_SUMMARY + fi From 9a19c4ba0e5d34937fd27d0eacf8aea99a56f8ce Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:31:43 +0530 Subject: [PATCH 12/16] chore: remove placeholder --- .github/placeholder.txt | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/placeholder.txt diff --git a/.github/placeholder.txt b/.github/placeholder.txt deleted file mode 100644 index b3a4252..0000000 --- a/.github/placeholder.txt +++ /dev/null @@ -1 +0,0 @@ -placeholder \ No newline at end of file From ea7b4a58bd86414e08f6592266cadfefc04ea470 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:35:36 +0530 Subject: [PATCH 13/16] =?UTF-8?q?fix:=20use=20step=20output=20for=20keysto?= =?UTF-8?q?re=20check=20instead=20of=20inline=20secrets=20=F0=9F=90=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build.yml | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7271839..ac4c30c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -81,19 +81,24 @@ jobs: if-no-files-found: error compression-level: 0 - # --- Signing & Release Build (optional) --- + # --- Signing & Release Build (only when keystore secret exists) --- + - name: 🔐 Check if keystore is available + id: check_keystore + run: | + if [ -n "${{ secrets.KEYSTORE_BASE64 }}" ]; then + echo "available=true" >> $GITHUB_OUTPUT + else + echo "available=false" >> $GITHUB_OUTPUT + fi + shell: bash + - name: 🔐 Decode Keystore - id: decode_keystore - if: | - github.event_name != 'pull_request' && - ( startsWith(github.ref, 'refs/tags/') || github.event_name == 'workflow_dispatch' ) && - secrets.KEYSTORE_BASE64 != '' + if: steps.check_keystore.outputs.available == 'true' run: | echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/keystore.jks - echo "keystore_exists=true" >> $GITHUB_OUTPUT - name: 🏗️ Build Release APK (signed) - if: steps.decode_keystore.outputs.keystore_exists == 'true' + if: steps.check_keystore.outputs.available == 'true' run: | ./gradlew assembleRelease \ -Pandroid.injected.signing.store.file=app/keystore.jks \ @@ -103,7 +108,7 @@ jobs: --no-daemon --stacktrace - name: 📤 Upload Release APK - if: steps.decode_keystore.outputs.keystore_exists == 'true' + if: steps.check_keystore.outputs.available == 'true' uses: actions/upload-artifact@v4 with: name: ClawDroid-Release-${{ github.sha }} From 54c2a10bf21b9211d0105348de098174fee05107 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:46:43 +0530 Subject: [PATCH 14/16] =?UTF-8?q?fix:=20add=20ChatUtils.kt=20with=20missin?= =?UTF-8?q?g=20utility=20functions=20=F0=9F=9B=A0=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/clawdroid/app/ui/chat/ChatUtils.kt | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 app/src/main/java/com/clawdroid/app/ui/chat/ChatUtils.kt diff --git a/app/src/main/java/com/clawdroid/app/ui/chat/ChatUtils.kt b/app/src/main/java/com/clawdroid/app/ui/chat/ChatUtils.kt new file mode 100644 index 0000000..cbf05f2 --- /dev/null +++ b/app/src/main/java/com/clawdroid/app/ui/chat/ChatUtils.kt @@ -0,0 +1,164 @@ +package com.clawdroid.app.ui.chat + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.core.graphics.drawable.toBitmap +import androidx.compose.ui.platform.LocalContext +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream + +/** + * Copy a URI to a local cache file and return the File. + */ +fun copyUriToCache(context: Context, uri: Uri): File? { + return try { + val cursor = context.contentResolver.query(uri, null, null, null, null) + val nameIndex = cursor?.getColumnIndex(OpenableColumns.DISPLAY_NAME) + cursor?.moveToFirst() + val fileName = if (nameIndex != null && nameIndex >= 0) { + cursor?.getString(nameIndex) ?: "cached_file_${System.currentTimeMillis()}" + } else { + "cached_file_${System.currentTimeMillis()}" + } + cursor?.close() + + val cacheFile = File(context.cacheDir, fileName) + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(cacheFile).use { output -> + input.copyTo(output) + } + } + cacheFile + } catch (e: Exception) { + null + } +} + +/** + * Extract a JSON field from a possibly-malformed JSON string. + */ +fun extractJsonField(json: String, field: String): String? { + return try { + // Try direct JSON parsing first + val obj = JSONObject(json) + if (obj.has(field)) obj.optString(field) else null + } catch (e: Exception) { + // Fallback: regex extraction for malformed JSON + val regex = Regex("\"$field\"\\s*:\\s*\"([^\"]*)\"") + regex.find(json)?.groupValues?.getOrNull(1) + } +} + +/** + * Convert a tool name (snake_case) to a human-readable name. + */ +fun String.readableToolName(): String { + return this + .replace("_", " ") + .split(" ") + .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } +} + +/** + * Convert a tool name to an ActivityStepType string. + */ +fun String.toActivityStepType(): String { + return when { + this.contains("write") || this.contains("file") -> "file_write" + this.contains("read") || this.contains("search") -> "search" + this.contains("edit") -> "file_edit" + this.contains("terminal") || this.contains("command") || this.contains("exec") -> "command" + this.contains("web") || this.contains("browse") || this.contains("http") -> "web" + this.contains("think") || this.contains("reason") -> "think" + else -> "tool" + } +} + +/** + * Format byte count to human-readable string. + */ +fun formatBytes(bytes: Long): String { + return when { + bytes < 1024 -> "$bytes B" + bytes < 1024 * 1024 -> "${bytes / 1024} KB" + bytes < 1024 * 1024 * 1024 -> "${"%.1f".format(bytes.toDouble() / (1024 * 1024))} MB" + else -> "${"%.2f".format(bytes.toDouble() / (1024 * 1024 * 1024))} GB" + } +} + +/** + * Format diff text for display in chat. + */ +fun formatDiffDisplayText(text: String): String { + if (text.length <= 200) return text + return text.take(200) + "..." +} + +/** + * Load a bitmap from URI (Composable-friendly). + */ +@Composable +fun rememberBitmapFromUri(uri: Uri?): ImageBitmap? { + val context = LocalContext.current + return remember(uri) { + if (uri == null) null + else try { + val inputStream = context.contentResolver.openInputStream(uri) + val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream) + inputStream?.close() + bitmap?.asImageBitmap() + } catch (e: Exception) { + null + } + } +} + +/** + * Build file preview data from a list of items. + */ +fun buildFilePreviews(items: List): List { + return items.mapNotNull { item -> + when (item) { + is String -> FilePreviewData( + name = item.substringAfterLast("/").substringAfterLast("\\"), + type = item.substringAfterLast(".").take(10), + preview = item + ) + else -> null + } + } +} + +data class FilePreviewData( + val name: String, + val type: String, + val preview: String +) + +/** + * Get metadata (size, type, name) for a URI. + */ +data class UriMetadata(val name: String, val size: Long, val mimeType: String) + +fun getUriMetadata(context: Context, uri: Uri): UriMetadata? { + return try { + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val nameIdx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = it.getColumnIndex(OpenableColumns.SIZE) + val name = if (nameIdx >= 0) it.getString(nameIdx) else "unknown" + val size = if (sizeIdx >= 0) it.getLong(sizeIdx) else 0L + val mime = context.contentResolver.getType(uri) ?: "application/octet-stream" + UriMetadata(name, size, mime) + } else null + } + } catch (e: Exception) { + null + } +} From bd6d3c65021b43e8c0f9808f8173fde7fd3ad3ee Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:48:57 +0530 Subject: [PATCH 15/16] =?UTF-8?q?fix:=20close=20unclosed=20AnimatedVisibil?= =?UTF-8?q?ity=20+=20balanced=20braces=20=F0=9F=A7=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/clawdroid/app/ui/chat/ChatScreen.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/clawdroid/app/ui/chat/ChatScreen.kt b/app/src/main/java/com/clawdroid/app/ui/chat/ChatScreen.kt index 1edde81..bf7b7c0 100644 --- a/app/src/main/java/com/clawdroid/app/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/clawdroid/app/ui/chat/ChatScreen.kt @@ -1443,7 +1443,6 @@ private fun InlineActivityTrail( } } -@Composable @Composable private fun InlineActivityStep(step: ActivityStepItem) { var expanded by remember(step.running) { mutableStateOf(step.running) } @@ -1509,6 +1508,17 @@ private fun InlineActivityStep(step: ActivityStepItem) { } } AnimatedVisibility(visible = expanded) { + if (step.detail.isNotBlank()) { + Text( + text = step.detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(top = 4.dp) + ) + } + } + } +} @Composable private fun PremiumInputBar( From 08ea11bcdb4cb70aea335cfd370f3820e0605ec9 Mon Sep 17 00:00:00 2001 From: ClawDroid Date: Thu, 18 Jun 2026 14:49:06 +0530 Subject: [PATCH 16/16] chore: remove ChatUtils.kt (functions already in ChatScreen.kt) --- .../com/clawdroid/app/ui/chat/ChatUtils.kt | 164 ------------------ 1 file changed, 164 deletions(-) delete mode 100644 app/src/main/java/com/clawdroid/app/ui/chat/ChatUtils.kt diff --git a/app/src/main/java/com/clawdroid/app/ui/chat/ChatUtils.kt b/app/src/main/java/com/clawdroid/app/ui/chat/ChatUtils.kt deleted file mode 100644 index cbf05f2..0000000 --- a/app/src/main/java/com/clawdroid/app/ui/chat/ChatUtils.kt +++ /dev/null @@ -1,164 +0,0 @@ -package com.clawdroid.app.ui.chat - -import android.content.Context -import android.net.Uri -import android.provider.OpenableColumns -import androidx.compose.runtime.* -import androidx.compose.ui.graphics.ImageBitmap -import androidx.compose.ui.graphics.asImageBitmap -import androidx.core.graphics.drawable.toBitmap -import androidx.compose.ui.platform.LocalContext -import org.json.JSONObject -import java.io.File -import java.io.FileOutputStream - -/** - * Copy a URI to a local cache file and return the File. - */ -fun copyUriToCache(context: Context, uri: Uri): File? { - return try { - val cursor = context.contentResolver.query(uri, null, null, null, null) - val nameIndex = cursor?.getColumnIndex(OpenableColumns.DISPLAY_NAME) - cursor?.moveToFirst() - val fileName = if (nameIndex != null && nameIndex >= 0) { - cursor?.getString(nameIndex) ?: "cached_file_${System.currentTimeMillis()}" - } else { - "cached_file_${System.currentTimeMillis()}" - } - cursor?.close() - - val cacheFile = File(context.cacheDir, fileName) - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(cacheFile).use { output -> - input.copyTo(output) - } - } - cacheFile - } catch (e: Exception) { - null - } -} - -/** - * Extract a JSON field from a possibly-malformed JSON string. - */ -fun extractJsonField(json: String, field: String): String? { - return try { - // Try direct JSON parsing first - val obj = JSONObject(json) - if (obj.has(field)) obj.optString(field) else null - } catch (e: Exception) { - // Fallback: regex extraction for malformed JSON - val regex = Regex("\"$field\"\\s*:\\s*\"([^\"]*)\"") - regex.find(json)?.groupValues?.getOrNull(1) - } -} - -/** - * Convert a tool name (snake_case) to a human-readable name. - */ -fun String.readableToolName(): String { - return this - .replace("_", " ") - .split(" ") - .joinToString(" ") { it.replaceFirstChar { c -> c.uppercase() } } -} - -/** - * Convert a tool name to an ActivityStepType string. - */ -fun String.toActivityStepType(): String { - return when { - this.contains("write") || this.contains("file") -> "file_write" - this.contains("read") || this.contains("search") -> "search" - this.contains("edit") -> "file_edit" - this.contains("terminal") || this.contains("command") || this.contains("exec") -> "command" - this.contains("web") || this.contains("browse") || this.contains("http") -> "web" - this.contains("think") || this.contains("reason") -> "think" - else -> "tool" - } -} - -/** - * Format byte count to human-readable string. - */ -fun formatBytes(bytes: Long): String { - return when { - bytes < 1024 -> "$bytes B" - bytes < 1024 * 1024 -> "${bytes / 1024} KB" - bytes < 1024 * 1024 * 1024 -> "${"%.1f".format(bytes.toDouble() / (1024 * 1024))} MB" - else -> "${"%.2f".format(bytes.toDouble() / (1024 * 1024 * 1024))} GB" - } -} - -/** - * Format diff text for display in chat. - */ -fun formatDiffDisplayText(text: String): String { - if (text.length <= 200) return text - return text.take(200) + "..." -} - -/** - * Load a bitmap from URI (Composable-friendly). - */ -@Composable -fun rememberBitmapFromUri(uri: Uri?): ImageBitmap? { - val context = LocalContext.current - return remember(uri) { - if (uri == null) null - else try { - val inputStream = context.contentResolver.openInputStream(uri) - val bitmap = android.graphics.BitmapFactory.decodeStream(inputStream) - inputStream?.close() - bitmap?.asImageBitmap() - } catch (e: Exception) { - null - } - } -} - -/** - * Build file preview data from a list of items. - */ -fun buildFilePreviews(items: List): List { - return items.mapNotNull { item -> - when (item) { - is String -> FilePreviewData( - name = item.substringAfterLast("/").substringAfterLast("\\"), - type = item.substringAfterLast(".").take(10), - preview = item - ) - else -> null - } - } -} - -data class FilePreviewData( - val name: String, - val type: String, - val preview: String -) - -/** - * Get metadata (size, type, name) for a URI. - */ -data class UriMetadata(val name: String, val size: Long, val mimeType: String) - -fun getUriMetadata(context: Context, uri: Uri): UriMetadata? { - return try { - val cursor = context.contentResolver.query(uri, null, null, null, null) - cursor?.use { - if (it.moveToFirst()) { - val nameIdx = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val sizeIdx = it.getColumnIndex(OpenableColumns.SIZE) - val name = if (nameIdx >= 0) it.getString(nameIdx) else "unknown" - val size = if (sizeIdx >= 0) it.getLong(sizeIdx) else 0L - val mime = context.contentResolver.getType(uri) ?: "application/octet-stream" - UriMetadata(name, size, mime) - } else null - } - } catch (e: Exception) { - null - } -}