diff --git a/.github/workflows/manual-aab-build.yml b/.github/workflows/manual-aab-build.yml new file mode 100644 index 00000000..54efaa17 --- /dev/null +++ b/.github/workflows/manual-aab-build.yml @@ -0,0 +1,118 @@ +name: Manual AAB Build (Release) + +on: + workflow_dispatch: # Manual workflow trigger only + +jobs: + build-aab: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Cache Gradle build cache + uses: actions/cache@v4 + with: + path: ~/.gradle/build-cache + key: gradle-build-cache-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', '**/gradle.properties', 'app/src/**') }} + restore-keys: | + gradle-build-cache-${{ runner.os }}- + + - name: Decode google-services.json (app) + env: + GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON_APP }} + run: printf '%s' "$GOOGLE_SERVICES_JSON" > app/google-services.json + + - name: Create local.properties + run: echo "sdk.dir=$ANDROID_HOME" > local.properties + + - name: Fix gradle.properties for CI + run: | + sed -i '/org.gradle.java.home=/d' gradle.properties + sed -i '/kotlin.daemon.jvmargs=/d' gradle.properties + sed -i 's/org.gradle.jvmargs=.*/org.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=512m/' gradle.properties + + - name: Decode Keystore + env: + KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + run: | + mkdir -p app/keystores + echo "$KEYSTORE_BASE64" | base64 -d > app/keystores/release.keystore + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build release AAB with 16KB alignment + env: + ANDROID_KEYSTORE_PATH: app/keystores/release.keystore + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} + run: | + ./gradlew :app:bundleRelease + echo "### 16KB Native Library Alignment ✅" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Native libraries verified for 16KB alignment (0x4000 = 16384 bytes) during Gradle build." >> $GITHUB_STEP_SUMMARY + echo "This is required for Android 15+ compatibility on 64-bit devices." >> $GITHUB_STEP_SUMMARY + echo "### Build Success ✅" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Release AAB bundle created successfully." >> $GITHUB_STEP_SUMMARY + + - name: Verify AAB file exists + run: | + if [ -f "app/build/outputs/bundle/release/app-release.aab" ]; then + echo "✅ AAB file found" + ls -lh app/build/outputs/bundle/release/app-release.aab + else + echo "❌ AAB file not found" + exit 1 + fi + + - name: Verify AAB zipalign (4-byte zip alignment) + run: | + echo "### Zipalign Verification (APK/AAB Zip Structure)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Verifying 4-byte alignment of AAB zip entries (separate from 16KB native library alignment)..." >> $GITHUB_STEP_SUMMARY + if $ANDROID_HOME/build-tools/*/zipalign -c -p 4 app/build/outputs/bundle/release/app-release.aab; then + echo "✅ AAB zip structure is properly aligned (4-byte boundaries)" | tee -a $GITHUB_STEP_SUMMARY + else + echo "❌ AAB zip alignment verification failed" | tee -a $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "" >> $GITHUB_STEP_SUMMARY + + - name: Get build information + id: build_info + run: | + BUILD_DATE=$(date +%Y%m%d-%H%M%S) + COMMIT_SHORT=$(git rev-parse --short HEAD) + echo "build_date=$BUILD_DATE" >> $GITHUB_OUTPUT + echo "commit_short=$COMMIT_SHORT" >> $GITHUB_OUTPUT + echo "artifact_name=app-release-${COMMIT_SHORT}-${BUILD_DATE}" >> $GITHUB_OUTPUT + + - name: Upload release AAB + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.build_info.outputs.artifact_name }} + path: app/build/outputs/bundle/release/app-release.aab + retention-days: 90 + + - name: Build summary + run: | + echo "### 📦 AAB Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY + echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Build Type | Release (Signed) |" >> $GITHUB_STEP_SUMMARY + echo "| Native Libs 16KB Alignment | ✅ Verified (Android 15+) |" >> $GITHUB_STEP_SUMMARY + echo "| Zip Structure 4-byte Alignment | ✅ Verified |" >> $GITHUB_STEP_SUMMARY + echo "| Commit | ${{ steps.build_info.outputs.commit_short }} |" >> $GITHUB_STEP_SUMMARY + echo "| Build Date | ${{ steps.build_info.outputs.build_date }} |" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index 81eb3007..17fddaae 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -188,16 +188,16 @@ jobs: path: humanoperator/build/outputs/apk/debug/humanoperator-debug.apk - name: Build app release AAB (unsigned) - if: env.BUILD_APP == 'true' - run: ./gradlew :app:bundleRelease - - - name: Upload app release AAB (unsigned) - if: env.BUILD_APP == 'true' - uses: actions/upload-artifact@v4 - with: - name: app-release-aab-unsigned - path: app/build/outputs/bundle/release/app-release.aab - + # - name: Build app release AAB (unsigned) + # if: env.BUILD_APP == 'true' + # run: ./gradlew :app:bundleRelease + # + # - name: Upload app release AAB (unsigned) + # if: env.BUILD_APP == 'true' + # uses: actions/upload-artifact@v4 + # with: + # name: app-release-aab-unsigned + # path: app/build/outputs/bundle/release/app-release.aab - name: Build summary run: | echo "### Build Summary" >> $GITHUB_STEP_SUMMARY diff --git a/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt b/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt index 4e95c87c..084a7423 100644 --- a/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt +++ b/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt @@ -40,6 +40,8 @@ enum class ModelOption( PUTER_GPT_5_4_NANO("GPT-5.4 Nano (Puter)", "openai/gpt-5.4-nano", ApiProvider.PUTER, supportsScreenshot = true), PUTER_MIMO_V2_5("Mimo-V2.5 (Puter)", "xiaomi/mimo-v2.5", ApiProvider.PUTER, supportsScreenshot = true), PUTER_QWEN3_5_FLASH("Qwen3.5-Flash (Puter)", "qwen/qwen3.5-flash-02-23", ApiProvider.PUTER, supportsScreenshot = true), + PUTER_AUTOGLM_PHONE_MULTILINGUAL("AutoGLM Phone Multilingual 9B (Puter)", "z-ai/autoglm-phone-multilingual", ApiProvider.PUTER, supportsScreenshot = true), + PUTER_MINIMAX_M3("MiniMax M3 (Puter)", "minimax/minimax-m3", ApiProvider.PUTER, supportsScreenshot = true), GROQ_LLAMA_4_SCOUT_17B("Llama 4 Scout 109B (Groq)", "meta-llama/llama-4-scout-17b-16e-instruct", ApiProvider.GROQ, supportsScreenshot = true), CLOUDFLARE_KIMI_K2_6("Kimi K2.6 (Cloudflare)", "@cf/moonshotai/kimi-k2.6", ApiProvider.CLOUDFLARE, supportsScreenshot = true), MISTRAL_LARGE_3("Mistral Large 3", "mistral-large-latest", ApiProvider.MISTRAL), diff --git a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt index 66cefa22..03c78246 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt @@ -297,6 +297,8 @@ fun MenuScreen( ModelOption.PUTER_MIMO_V2_5 -> "$0.14/M input | $0.28/M output" ModelOption.PUTER_QWEN3_5_FLASH -> "$0.07/M input | $0.26/M output" ModelOption.GROQ_LLAMA_4_SCOUT_17B -> "30 requests per Min" + ModelOption.PUTER_AUTOGLM_PHONE_MULTILINGUAL -> "$0/M input | $0/M output" + ModelOption.PUTER_MINIMAX_M3 -> "$0.3/M input | $1.2/M output" ModelOption.CLOUDFLARE_KIMI_K2_6 -> "Approx. 15 responses per day are free" ModelOption.GPT_5_1_CODEX_MAX, ModelOption.GPT_5_1_CODEX_MINI, diff --git a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureApiClients.kt b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureApiClients.kt index 211dd798..7c5a0e03 100644 --- a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureApiClients.kt +++ b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureApiClients.kt @@ -15,7 +15,9 @@ import kotlinx.serialization.json.JsonClassDiscriminator import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.subclass +import com.google.ai.sample.network.AiCallCancellationHandle import com.google.ai.sample.network.MistralRequestCoordinator +import kotlinx.coroutines.CancellationException import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request @@ -73,7 +75,8 @@ internal suspend fun callMistralApi( apiKey: String, chatHistory: List, inputContent: Content, - availableApiKeys: List = listOf(apiKey) + availableApiKeys: List = listOf(apiKey), + cancellationHandle: AiCallCancellationHandle? = null ): Pair { var responseText: String? = null var errorMessage: String? = null @@ -147,35 +150,62 @@ internal suspend fun callMistralApi( } else { maxOf(4, keysForCoordinator.size * 3) } - val coordinated = MistralRequestCoordinator.execute( - apiKeys = keysForCoordinator, - maxAttempts = maxAttempts, - minIntervalMs = minIntervalMs - ) { key -> - client.newCall( - request.newBuilder() - .header("Authorization", "Bearer $key") - .build() - ).execute() - } + try { + val coordinated = MistralRequestCoordinator.execute( + apiKeys = keysForCoordinator, + maxAttempts = maxAttempts, + minIntervalMs = minIntervalMs, + shouldCancel = { cancellationHandle?.isCancellationRequested == true } + ) { key -> + val call = client.newCall( + request.newBuilder() + .header("Authorization", "Bearer $key") + .build() + ) + cancellationHandle?.register(call) + try { + call.execute() + } catch (e: IOException) { + if (call.isCanceled() || cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Mistral API call cancelled by user").also { it.initCause(e) } + } + throw e + } + } - coordinated.response.use { response -> - val responseBody = response.body?.string() - if (!response.isSuccessful) { - Log.e("ScreenCaptureService", "Mistral API Error ($response.code): $responseBody") - errorMessage = "Mistral Error ${response.code}: $responseBody" - } else { - if (responseBody != null) { - val mistralResponse = json.decodeFromString(ServiceMistralResponse.serializer(), responseBody) - responseText = mistralResponse.choices.firstOrNull()?.message?.content ?: "No response from model" + coordinated.response.use { response -> + val responseBody = response.body?.string() + if (cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Mistral API call cancelled by user") + } + if (!response.isSuccessful) { + Log.e("ScreenCaptureService", "Mistral API Error ($response.code): $responseBody") + errorMessage = "Mistral Error ${response.code}: $responseBody" } else { - errorMessage = "Empty response body from Mistral" + if (responseBody != null) { + val mistralResponse = json.decodeFromString(ServiceMistralResponse.serializer(), responseBody) + responseText = mistralResponse.choices.firstOrNull()?.message?.content ?: "No response from model" + } else { + errorMessage = "Empty response body from Mistral" + } } } + } finally { + cancellationHandle?.clearCurrentCall() + } + } catch (e: CancellationException) { + if (cancellationHandle?.isCancellationRequested == true) { + Log.d("ScreenCaptureService", "Mistral API call cancelled by user", e) + } else { + throw e } } catch (e: IOException) { - errorMessage = e.localizedMessage ?: "Mistral API network call failed" - Log.e("ScreenCaptureService", "Mistral API network failure", e) + if (cancellationHandle?.isCancellationRequested == true) { + Log.d("ScreenCaptureService", "Mistral API network call cancelled by user", e) + } else { + errorMessage = e.localizedMessage ?: "Mistral API network call failed" + Log.e("ScreenCaptureService", "Mistral API network failure", e) + } } catch (e: SerializationException) { errorMessage = e.localizedMessage ?: "Mistral API response parse failed" Log.e("ScreenCaptureService", "Mistral API parse failure", e) @@ -187,7 +217,7 @@ internal suspend fun callMistralApi( return Pair(responseText, errorMessage) } -internal suspend fun callPuterApi(modelName: String, apiKey: String, chatHistory: List, inputContent: Content): Pair { +internal suspend fun callPuterApi(modelName: String, apiKey: String, chatHistory: List, inputContent: Content, cancellationHandle: AiCallCancellationHandle? = null): Pair { var responseText: String? = null var errorMessage: String? = null @@ -226,11 +256,21 @@ internal suspend fun callPuterApi(modelName: String, apiKey: String, chatHistory messages = apiMessages ) - responseText = com.google.ai.sample.network.PuterApiClient.call(apiKey, requestBody) + responseText = com.google.ai.sample.network.PuterApiClient.call(apiKey, requestBody, cancellationHandle) + } catch (e: CancellationException) { + if (cancellationHandle?.isCancellationRequested == true) { + Log.d("ScreenCaptureService", "Puter API call cancelled by user", e) + } else { + throw e + } } catch (e: IOException) { - errorMessage = e.localizedMessage ?: "Puter API network call failed" - Log.e("ScreenCaptureService", "Puter API network failure", e) + if (cancellationHandle?.isCancellationRequested == true) { + Log.d("ScreenCaptureService", "Puter API network call cancelled by user", e) + } else { + errorMessage = e.localizedMessage ?: "Puter API network call failed" + Log.e("ScreenCaptureService", "Puter API network failure", e) + } } catch (e: IllegalStateException) { errorMessage = e.localizedMessage ?: "Puter API call failed" Log.e("ScreenCaptureService", "Puter API state failure", e) @@ -272,7 +312,7 @@ data class ServiceGroqImageContent(@SerialName("image_url") val imageUrl: Servic @Serializable data class ServiceGroqImageUrl(val url: String) -internal suspend fun callGroqApi(modelName: String, apiKey: String, chatHistory: List, inputContent: Content): Pair { +internal suspend fun callGroqApi(modelName: String, apiKey: String, chatHistory: List, inputContent: Content, cancellationHandle: AiCallCancellationHandle? = null): Pair { var responseText: String? = null var errorMessage: String? = null @@ -328,20 +368,51 @@ internal suspend fun callGroqApi(modelName: String, apiKey: String, chatHistory: .addHeader("Authorization", "Bearer $apiKey") .build() - client.newCall(request).execute().use { response -> - val responseBody = response.body?.string() - if (!response.isSuccessful) { - errorMessage = "Groq Error ${response.code}: $responseBody" - } else if (!responseBody.isNullOrBlank()) { - val parsed = json.decodeFromString(ServiceMistralResponse.serializer(), responseBody) - responseText = parsed.choices.firstOrNull()?.message?.content ?: "No response from model" - } else { - errorMessage = "Empty response body from Groq" + val call = client.newCall(request) + cancellationHandle?.register(call) + try { + val response = try { + call.execute() + } catch (e: IOException) { + if (call.isCanceled() || cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Groq API call cancelled by user").also { it.initCause(e) } + } + throw e + } + if (cancellationHandle?.isCancellationRequested == true) { + response.close() + throw CancellationException("Groq API call cancelled by user") + } + response.use { response -> + val responseBody = response.body?.string() + if (cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Groq API call cancelled by user") + } + if (!response.isSuccessful) { + errorMessage = "Groq Error ${response.code}: $responseBody" + } else if (!responseBody.isNullOrBlank()) { + val parsed = json.decodeFromString(ServiceMistralResponse.serializer(), responseBody) + responseText = parsed.choices.firstOrNull()?.message?.content ?: "No response from model" + } else { + errorMessage = "Empty response body from Groq" + } } + } finally { + cancellationHandle?.clearCurrentCall() + } + } catch (e: CancellationException) { + if (cancellationHandle?.isCancellationRequested == true) { + Log.d("ScreenCaptureService", "Groq API call cancelled by user", e) + } else { + throw e } } catch (e: Exception) { - errorMessage = e.localizedMessage ?: "Groq API call failed" - Log.e("ScreenCaptureService", "Groq API failure", e) + if (cancellationHandle?.isCancellationRequested == true && e is IOException) { + Log.d("ScreenCaptureService", "Groq API network call cancelled by user", e) + } else { + errorMessage = e.localizedMessage ?: "Groq API call failed" + Log.e("ScreenCaptureService", "Groq API failure", e) + } } return Pair(responseText, errorMessage) diff --git a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt index a2a67a9a..e180aa5e 100644 --- a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt +++ b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureService.kt @@ -31,10 +31,12 @@ import com.google.ai.client.generativeai.type.BlobPart import com.google.ai.client.generativeai.type.TextPart import com.google.ai.sample.feature.multimodal.dtos.ContentDto import com.google.ai.sample.feature.multimodal.dtos.toSdk +import com.google.ai.sample.network.AiCallCancellationHandle import com.google.ai.sample.service.AiCallRequestExtras import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.serialization.SerialName @@ -76,6 +78,7 @@ class ScreenCaptureService : Service() { // For triggering AI call execution in the service const val ACTION_EXECUTE_AI_CALL = "com.google.ai.sample.EXECUTE_AI_CALL" + const val ACTION_CANCEL_AI_CALL = "com.google.ai.sample.CANCEL_AI_CALL" const val EXTRA_AI_INPUT_CONTENT_JSON = "com.google.ai.sample.EXTRA_AI_INPUT_CONTENT_JSON" const val EXTRA_AI_CHAT_HISTORY_JSON = "com.google.ai.sample.EXTRA_AI_CHAT_HISTORY_JSON" const val EXTRA_AI_MODEL_NAME = "com.google.ai.sample.EXTRA_AI_MODEL_NAME" // For service to create model @@ -102,6 +105,7 @@ class ScreenCaptureService : Service() { private var isReady = false // Flag to indicate if MediaProjection is set up and active private val isScreenshotRequestedRef = java.util.concurrent.atomic.AtomicBoolean(false) private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val aiCallCancellationHandle = AiCallCancellationHandle() // Callback for MediaProjection private val mediaProjectionCallback = object : MediaProjection.Callback() { @@ -201,6 +205,10 @@ class ScreenCaptureService : Service() { Log.d(TAG, "Received ACTION_STOP_CAPTURE. Cleaning up.") cleanup() } + ACTION_CANCEL_AI_CALL -> { + Log.d(TAG, "Received ACTION_CANCEL_AI_CALL. Cancelling active AI network call.") + aiCallCancellationHandle.cancel() + } ACTION_EXECUTE_AI_CALL -> { Log.d(TAG, "ACTION_EXECUTE_AI_CALL: Ensuring foreground state for AI processing.") val aiNotification = createAiOperationNotification() @@ -244,6 +252,7 @@ class ScreenCaptureService : Service() { return START_STICKY // Or START_NOT_STICKY if this is a fatal error for this call } + aiCallCancellationHandle.reset() serviceScope.launch { var responseText: String? = null var errorMessage: String? = null @@ -295,7 +304,7 @@ class ScreenCaptureService : Service() { } try { if (apiProvider == ApiProvider.VERCEL) { - responseText = callVercelApi(applicationContext, modelName, apiKey, chatHistoryDtos, inputContentDto) + responseText = callVercelApi(applicationContext, modelName, apiKey, chatHistoryDtos, inputContentDto, aiCallCancellationHandle) } else if (apiProvider == ApiProvider.MISTRAL) { val availableMistralKeys = ApiKeyManager.getInstance(applicationContext) .getApiKeys(ApiProvider.MISTRAL) @@ -305,16 +314,17 @@ class ScreenCaptureService : Service() { apiKey = apiKey, chatHistory = chatHistory, inputContent = inputContent, - availableApiKeys = availableMistralKeys + availableApiKeys = availableMistralKeys, + cancellationHandle = aiCallCancellationHandle ) responseText = result.first errorMessage = result.second } else if (apiProvider == ApiProvider.PUTER) { - val result = callPuterApi(modelName, apiKey, chatHistory, inputContent) + val result = callPuterApi(modelName, apiKey, chatHistory, inputContent, aiCallCancellationHandle) responseText = result.first errorMessage = result.second } else if (apiProvider == ApiProvider.GROQ) { - val result = callGroqApi(modelName, apiKey, chatHistory, inputContent) + val result = callGroqApi(modelName, apiKey, chatHistory, inputContent, aiCallCancellationHandle) responseText = result.first errorMessage = result.second } else { @@ -335,6 +345,12 @@ class ScreenCaptureService : Service() { } responseText = fullResponse.toString() } + } catch (e: CancellationException) { + if (aiCallCancellationHandle.isCancellationRequested) { + Log.d(TAG, "AI call cancelled by user", e) + } else { + throw e + } } catch (e: MissingFieldException) { Log.e(TAG, "Serialization error, potentially a 503 error.", e) // Point 15: Check for missing 'parts' field (Gemma 27B issue) @@ -356,6 +372,12 @@ class ScreenCaptureService : Service() { } } + } catch (e: CancellationException) { + if (aiCallCancellationHandle.isCancellationRequested) { + Log.d(TAG, "AI call cancelled by user", e) + } else { + throw e + } } catch (e: Exception) { // Catching general exceptions from model/chat operations or serialization Log.e(TAG, "Outer error during AI call execution", e) @@ -384,7 +406,7 @@ class ScreenCaptureService : Service() { } } - if (errorMessage != null || responseText != null) { + if (!aiCallCancellationHandle.isCancellationRequested && (errorMessage != null || responseText != null)) { LocalBroadcastManager.getInstance(applicationContext).sendBroadcast(resultIntent) Log.d(TAG, "Local broadcast sent for AI_CALL_RESULT. Error: $errorMessage, Response: ${responseText != null}") } diff --git a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureVercelClient.kt b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureVercelClient.kt index 8c408e05..79c47644 100644 --- a/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureVercelClient.kt +++ b/app/src/main/kotlin/com/google/ai/sample/ScreenCaptureVercelClient.kt @@ -3,6 +3,8 @@ package com.google.ai.sample import android.content.Intent import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.google.ai.sample.feature.multimodal.dtos.ContentDto +import com.google.ai.sample.network.AiCallCancellationHandle +import kotlinx.coroutines.CancellationException import kotlinx.serialization.Serializable import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString @@ -48,10 +50,11 @@ internal suspend fun callVercelApi( modelName: String, apiKey: String, chatHistory: List, - inputContent: ContentDto + inputContent: ContentDto, + cancellationHandle: AiCallCancellationHandle? = null ): String { val messages = mutableListOf() - + // Add Chat History chatHistory.forEach { contentDto -> val role = if (contentDto.role == "user") "user" else "assistant" @@ -76,48 +79,76 @@ internal suspend fun callVercelApi( .build() val client = OkHttpClient() - val response = client.newCall(httpRequest).execute() + val call = client.newCall(httpRequest) + cancellationHandle?.register(call) + try { + val response = try { + call.execute() + } catch (e: IOException) { + if (call.isCanceled() || cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Vercel API call cancelled by user").also { it.initCause(e) } + } + throw e + } + if (cancellationHandle?.isCancellationRequested == true) { + response.close() + throw CancellationException("Vercel API call cancelled by user") + } - if (!response.isSuccessful) { - val err = response.body?.string() - response.close() - throw IOException("Vercel API error ${response.code}: $err") - } + if (!response.isSuccessful) { + val err = response.body?.string() + response.close() + throw IOException("Vercel API error ${response.code}: $err") + } - val body = response.body ?: throw IOException("Empty response from Vercel") - val reader = body.charStream().buffered() - val accumulated = StringBuilder() - val sseJson = Json { ignoreUnknownKeys = true } + val body = response.body ?: run { + response.close() + throw IOException("Empty response from Vercel") + } + val reader = body.charStream().buffered() + val accumulated = StringBuilder() + val sseJson = Json { ignoreUnknownKeys = true } - try { - var line: String? - while (reader.readLine().also { line = it } != null) { - val l = line ?: break - if (l.startsWith("data: ")) { - val data = l.removePrefix("data: ").trim() - if (data == "[DONE]") break - if (data.isEmpty()) continue - - try { - val chunk = sseJson.decodeFromString(data) - val delta = chunk.choices.firstOrNull()?.delta?.content - if (!delta.isNullOrEmpty()) { - accumulated.append(delta) - // Broadcast update to ViewModel - val intent = Intent(ScreenCaptureService.ACTION_AI_STREAM_UPDATE).apply { - putExtra(ScreenCaptureService.EXTRA_AI_STREAM_CHUNK, accumulated.toString()) + try { + var line: String? + while (reader.readLine().also { line = it } != null) { + if (cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Vercel API call cancelled by user") + } + val l = line ?: break + if (l.startsWith("data: ")) { + val data = l.removePrefix("data: ").trim() + if (data == "[DONE]") break + if (data.isEmpty()) continue + + try { + val chunk = sseJson.decodeFromString(data) + val delta = chunk.choices.firstOrNull()?.delta?.content + if (!delta.isNullOrEmpty()) { + accumulated.append(delta) + // Broadcast update to ViewModel + val intent = Intent(ScreenCaptureService.ACTION_AI_STREAM_UPDATE).apply { + putExtra(ScreenCaptureService.EXTRA_AI_STREAM_CHUNK, accumulated.toString()) + } + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) } - LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + } catch (e: SerializationException) { + // Skip malformed chunks } - } catch (e: SerializationException) { - // Skip malformed chunks } } + } finally { + reader.close() + response.close() } + + return accumulated.toString() + } catch (e: IOException) { + if (cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Vercel API call cancelled by user").also { it.initCause(e) } + } + throw e } finally { - reader.close() - response.close() + cancellationHandle?.clearCurrentCall() } - - return accumulated.toString() } diff --git a/app/src/main/kotlin/com/google/ai/sample/network/AiCallCancellationHandle.kt b/app/src/main/kotlin/com/google/ai/sample/network/AiCallCancellationHandle.kt new file mode 100644 index 00000000..ceda1521 --- /dev/null +++ b/app/src/main/kotlin/com/google/ai/sample/network/AiCallCancellationHandle.kt @@ -0,0 +1,34 @@ +package com.google.ai.sample.network + +import okhttp3.Call +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +internal class AiCallCancellationHandle { + private val cancellationRequested = AtomicBoolean(false) + private val currentCall = AtomicReference(null) + + val isCancellationRequested: Boolean + get() = cancellationRequested.get() + + fun reset() { + cancellationRequested.set(false) + currentCall.set(null) + } + + fun register(call: Call) { + currentCall.set(call) + if (cancellationRequested.get()) { + call.cancel() + } + } + + fun clearCurrentCall() { + currentCall.set(null) + } + + fun cancel() { + cancellationRequested.set(true) + currentCall.get()?.cancel() + } +} diff --git a/app/src/main/kotlin/com/google/ai/sample/network/MistralRequestCoordinator.kt b/app/src/main/kotlin/com/google/ai/sample/network/MistralRequestCoordinator.kt index 69d9821d..3deca68b 100644 --- a/app/src/main/kotlin/com/google/ai/sample/network/MistralRequestCoordinator.kt +++ b/app/src/main/kotlin/com/google/ai/sample/network/MistralRequestCoordinator.kt @@ -1,6 +1,7 @@ package com.google.ai.sample.network import android.util.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -18,6 +19,7 @@ internal object MistralRequestCoordinator { private const val TAG = "MistralCoordinator" private const val MIN_INTERVAL_MS = 1500L private const val MAX_SERVER_DELAY_MS = 5_000L + private const val CANCEL_CHECK_INTERVAL_MS = 100L private val cooldownMutex = Mutex() private val nextAllowedRequestAtMsByKey = mutableMapOf() private val requestId = AtomicLong(0L) @@ -75,10 +77,26 @@ internal object MistralRequestCoordinator { private fun isRetryableFailure(code: Int): Boolean = code == 429 || code >= 500 + private suspend fun delayUntilReady(waitMs: Long, shouldCancel: () -> Boolean) { + var remainingMs = waitMs + while (remainingMs > 0L) { + if (shouldCancel()) { + throw CancellationException("Mistral request cancelled by user") + } + val nextDelayMs = remainingMs.coerceAtMost(CANCEL_CHECK_INTERVAL_MS) + delay(nextDelayMs) + remainingMs -= nextDelayMs + } + if (shouldCancel()) { + throw CancellationException("Mistral request cancelled by user") + } + } + suspend fun execute( apiKeys: List, maxAttempts: Int = apiKeys.size * 4 + 8, minIntervalMs: Long = MIN_INTERVAL_MS, + shouldCancel: () -> Boolean = { false }, request: suspend (apiKey: String) -> Response ): MistralCoordinatedResponse { require(apiKeys.isNotEmpty()) { "No Mistral API keys provided." } @@ -108,12 +126,17 @@ internal object MistralRequestCoordinator { TAG, "[$rid] attempt=${consecutiveFailures + 1}, selectedKey=${keyFingerprint(selectedKey)}, waitMs=$waitMs, blocked=${blockedKeysThisRound.size}" ) - if (waitMs > 0L) { - delay(waitMs) - } + delayUntilReady(waitMs, shouldCancel) try { + if (shouldCancel()) { + throw CancellationException("Mistral request cancelled by user") + } val response = request(selectedKey) + if (shouldCancel()) { + response.close() + throw CancellationException("Mistral request cancelled by user") + } val requestEndMs = System.currentTimeMillis() val retryAfterMs = parseRetryAfterMs(response.header("Retry-After")) val resetDelayMs = parseRateLimitResetDelayMs(response, requestEndMs) @@ -138,7 +161,13 @@ internal object MistralRequestCoordinator { "[$rid] retryable failure code=${response.code}, consecutiveFailures=$consecutiveFailures, adaptiveDelay=$adaptiveDelay" ) markKeyCooldown(selectedKey, requestEndMs, minIntervalMs, max(serverRequestedDelayMs, adaptiveDelay)) + } catch (e: CancellationException) { + Log.d(TAG, "[$rid] cancelled by user") + throw e } catch (e: Exception) { + if (shouldCancel()) { + throw CancellationException("Mistral request cancelled by user").also { it.initCause(e) } + } val requestEndMs = System.currentTimeMillis() blockedKeysThisRound.add(selectedKey) consecutiveFailures++ diff --git a/app/src/main/kotlin/com/google/ai/sample/network/PuterApiClient.kt b/app/src/main/kotlin/com/google/ai/sample/network/PuterApiClient.kt index aff8e811..ef9fa919 100644 --- a/app/src/main/kotlin/com/google/ai/sample/network/PuterApiClient.kt +++ b/app/src/main/kotlin/com/google/ai/sample/network/PuterApiClient.kt @@ -89,7 +89,7 @@ object PuterApiClient { return "data:image/jpeg;base64,$base64" } - suspend fun call(apiKey: String, requestBody: PuterRequest): String { + internal suspend fun call(apiKey: String, requestBody: PuterRequest, cancellationHandle: AiCallCancellationHandle? = null): String { val mediaType = "application/json".toMediaType() val jsonBody = jsonConfig.encodeToString(PuterRequest.serializer(), requestBody) @@ -100,17 +100,38 @@ object PuterApiClient { .addHeader("Authorization", "Bearer $apiKey") .build() - return client.newCall(request).execute().use { response -> - val responseBodyString = response.body?.string() - if (!response.isSuccessful) { - throw IOException("Puter API Error (${response.code}): $responseBodyString") + val call = client.newCall(request) + cancellationHandle?.register(call) + try { + val response = try { + call.execute() + } catch (e: IOException) { + if (call.isCanceled() || cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Puter API call cancelled by user").also { it.initCause(e) } + } + throw e } - if (responseBodyString == null) { - throw IOException("Empty response body from Puter") + if (cancellationHandle?.isCancellationRequested == true) { + response.close() + throw CancellationException("Puter API call cancelled by user") } - - val puterResponse = jsonConfig.decodeFromString(PuterResponse.serializer(), responseBodyString) - puterResponse.choices.firstOrNull()?.message?.content ?: throw IOException("No response from model") + return response.use { response -> + val responseBodyString = response.body?.string() + if (cancellationHandle?.isCancellationRequested == true) { + throw CancellationException("Puter API call cancelled by user") + } + if (!response.isSuccessful) { + throw IOException("Puter API Error (${response.code}): $responseBodyString") + } + if (responseBodyString == null) { + throw IOException("Empty response body from Puter") + } + + val puterResponse = jsonConfig.decodeFromString(PuterResponse.serializer(), responseBodyString) + puterResponse.choices.firstOrNull()?.message?.content ?: throw IOException("No response from model") + } + } finally { + cancellationHandle?.clearCurrentCall() } } }