diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ac4c30c --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,182 @@ +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 (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 + if: steps.check_keystore.outputs.available == 'true' + run: | + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > app/keystore.jks + + - name: ๐Ÿ—๏ธ Build Release APK (signed) + if: steps.check_keystore.outputs.available == '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.check_keystore.outputs.available == '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 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..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 @@ -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,7 +1428,15 @@ 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) + } + } } } } @@ -1415,20 +1446,55 @@ private fun InlineActivityTrail( @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,95 +1502,19 @@ 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 - ) - ) - } - } + 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) + ) } } } @@ -2744,3 +2734,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") {