From c8f9d8c62fdf5c3b11d60f3219b3f93cbf1801eb Mon Sep 17 00:00:00 2001 From: Android PowerUser <88908510+Android-PowerUser@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:38:22 +0200 Subject: [PATCH 1/7] Cancel active OkHttp AI calls --- .../ai/sample/ScreenCaptureApiClients.kt | 151 +++++++++++++----- .../google/ai/sample/ScreenCaptureService.kt | 32 +++- .../ai/sample/ScreenCaptureVercelClient.kt | 103 +++++++----- .../network/AiCallCancellationHandle.kt | 34 ++++ .../network/MistralRequestCoordinator.kt | 35 +++- .../ai/sample/network/PuterApiClient.kt | 41 +++-- 6 files changed, 302 insertions(+), 94 deletions(-) create mode 100644 app/src/main/kotlin/com/google/ai/sample/network/AiCallCancellationHandle.kt 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() } } } From 22d55fed46f66ce7a29a4afd40c04d5880a3128f Mon Sep 17 00:00:00 2001 From: "amazon-q-developer[bot]" <208079219+amazon-q-developer[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:30:05 +0000 Subject: [PATCH 2/7] [skip ci] Add AutoGLM Phone Multilingual 9B model to Puter provider - Add PUTER_AUTOGLM_PHONE_MULTILINGUAL model option with z-ai/autoglm-phone-multilingual ID - Add pricing information: $0/M input | $0/M output - Model supports screenshots --- .../kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt | 1 + app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt | 1 + 2 files changed, 2 insertions(+) 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..2c3ec27c 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,7 @@ 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), 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..88135c00 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,7 @@ 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.CLOUDFLARE_KIMI_K2_6 -> "Approx. 15 responses per day are free" ModelOption.GPT_5_1_CODEX_MAX, ModelOption.GPT_5_1_CODEX_MINI, From 6439bcbfa3284094512c4163391a2bf44b12e024 Mon Sep 17 00:00:00 2001 From: "amazon-q-developer[bot]" <208079219+amazon-q-developer[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:27:01 +0000 Subject: [PATCH 3/7] [skip ci] Add MiniMax M3 (Puter) model with pricing information - Added PUTER_MINIMAX_M3 model option with model ID "minimax/minimax-m3" - Model supports screenshots - Added pricing hint: $0.3/M input | $1.2/M output --- .../kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt | 1 + app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt | 1 + 2 files changed, 2 insertions(+) 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 2c3ec27c..084a7423 100644 --- a/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt +++ b/app/src/main/kotlin/com/google/ai/sample/GenerativeAiViewModelFactory.kt @@ -41,6 +41,7 @@ enum class ModelOption( 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 88135c00..03c78246 100644 --- a/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt +++ b/app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt @@ -298,6 +298,7 @@ fun MenuScreen( 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, From 80268af73afe5aa33122dcdaabddef466b5ad8ad Mon Sep 17 00:00:00 2001 From: "amazon-q-developer[bot]" <208079219+amazon-q-developer[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:33:19 +0000 Subject: [PATCH 4/7] [skip ci] Comment out AAB build in GitHub Actions to speed up workflow --- .github/workflows/manual.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 From 3464d8bee4852a995341e693c578a06c1675f513 Mon Sep 17 00:00:00 2001 From: "amazon-q-developer[bot]" <208079219+amazon-q-developer[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:38:30 +0000 Subject: [PATCH 5/7] [skip ci] Add manual AAB build workflow with 16KB alignment verification - Create separate workflow for manual AAB builds (.github/workflows/manual-aab-build.yml) - Include release signing with keystore from GitHub secrets - Verify 16KB PT_LOAD alignment as required by Google Play - Upload signed AAB artifact with build metadata - Supports manual triggering only via workflow_dispatch --- .github/workflows/manual-aab-build.yml | 100 +++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/manual-aab-build.yml diff --git a/.github/workflows/manual-aab-build.yml b/.github/workflows/manual-aab-build.yml new file mode 100644 index 00000000..81158df1 --- /dev/null +++ b/.github/workflows/manual-aab-build.yml @@ -0,0 +1,100 @@ +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 "### Build Success ✅" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Release AAB bundle created successfully with 16KB alignment verification." >> $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: 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 "| 16KB 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 From 1aebc1dc33c6a3921f0fdb6595a1e04e4696b48e Mon Sep 17 00:00:00 2001 From: "amazon-q-developer[bot]" <208079219+amazon-q-developer[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:43:37 +0000 Subject: [PATCH 6/7] [skip ci] Add zipalign verification to AAB build workflow - Add zipalign verification step after AAB build using 'zipalign -c 4' - Verify 4-byte alignment of the AAB file - Display verification results in GitHub Actions summary - Fail build if zipalign verification does not pass --- .github/workflows/manual-aab-build.yml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/workflows/manual-aab-build.yml b/.github/workflows/manual-aab-build.yml index 81158df1..57d561a1 100644 --- a/.github/workflows/manual-aab-build.yml +++ b/.github/workflows/manual-aab-build.yml @@ -72,6 +72,17 @@ jobs: exit 1 fi + - name: Verify zipalign + run: | + echo "### Zipalign Verification" >> $GITHUB_STEP_SUMMARY + if $ANDROID_HOME/build-tools/*/zipalign -c 4 app/build/outputs/bundle/release/app-release.aab; then + echo "✅ AAB is properly aligned (4-byte alignment verified)" | tee -a $GITHUB_STEP_SUMMARY + else + echo "❌ AAB zipalign verification failed" | tee -a $GITHUB_STEP_SUMMARY + exit 1 + fi + echo "" >> $GITHUB_STEP_SUMMARY + - name: Get build information id: build_info run: | @@ -96,5 +107,6 @@ jobs: echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY echo "| Build Type | Release (Signed) |" >> $GITHUB_STEP_SUMMARY echo "| 16KB Alignment | ✅ Verified |" >> $GITHUB_STEP_SUMMARY + echo "| Zipalign | ✅ 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 From 86f5995762bb2d63e6f2459ab7036acc4d312db0 Mon Sep 17 00:00:00 2001 From: "amazon-q-developer[bot]" <208079219+amazon-q-developer[bot]@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:47:57 +0000 Subject: [PATCH 7/7] [skip ci] Fix AAB build alignment verification: clarify 16KB native lib vs 4-byte zip alignment - Update zipalign command to use correct flags for AAB (-c -p 4) - Add detailed documentation in workflow about two different alignment requirements: * 16KB (16384 bytes) native library alignment for Android 15+ (verified by Gradle) * 4-byte APK/AAB zip structure alignment (verified by zipalign) - Improve build summary to distinguish between the two alignment checks - Add explanatory messages during build process for clarity The build.gradle.kts already correctly verifies 16KB alignment (0x4000) for native libraries using readelf. This commit ensures the workflow properly documents and verifies both alignment requirements according to Google Play and Android 15+ rules. --- .github/workflows/manual-aab-build.yml | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/manual-aab-build.yml b/.github/workflows/manual-aab-build.yml index 57d561a1..54efaa17 100644 --- a/.github/workflows/manual-aab-build.yml +++ b/.github/workflows/manual-aab-build.yml @@ -58,9 +58,13 @@ jobs: 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 with 16KB alignment verification." >> $GITHUB_STEP_SUMMARY + echo "Release AAB bundle created successfully." >> $GITHUB_STEP_SUMMARY - name: Verify AAB file exists run: | @@ -72,13 +76,15 @@ jobs: exit 1 fi - - name: Verify zipalign + - name: Verify AAB zipalign (4-byte zip alignment) run: | - echo "### Zipalign Verification" >> $GITHUB_STEP_SUMMARY - if $ANDROID_HOME/build-tools/*/zipalign -c 4 app/build/outputs/bundle/release/app-release.aab; then - echo "✅ AAB is properly aligned (4-byte alignment verified)" | tee -a $GITHUB_STEP_SUMMARY + 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 zipalign verification failed" | tee -a $GITHUB_STEP_SUMMARY + echo "❌ AAB zip alignment verification failed" | tee -a $GITHUB_STEP_SUMMARY exit 1 fi echo "" >> $GITHUB_STEP_SUMMARY @@ -106,7 +112,7 @@ jobs: echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY echo "| Build Type | Release (Signed) |" >> $GITHUB_STEP_SUMMARY - echo "| 16KB Alignment | ✅ Verified |" >> $GITHUB_STEP_SUMMARY - echo "| Zipalign | ✅ Verified |" >> $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