Skip to content
118 changes: 118 additions & 0 deletions .github/workflows/manual-aab-build.yml
Original file line number Diff line number Diff line change
@@ -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
20 changes: 10 additions & 10 deletions .github/workflows/manual.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/kotlin/com/google/ai/sample/MenuScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
151 changes: 111 additions & 40 deletions app/src/main/kotlin/com/google/ai/sample/ScreenCaptureApiClients.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -73,7 +75,8 @@ internal suspend fun callMistralApi(
apiKey: String,
chatHistory: List<Content>,
inputContent: Content,
availableApiKeys: List<String> = listOf(apiKey)
availableApiKeys: List<String> = listOf(apiKey),
cancellationHandle: AiCallCancellationHandle? = null
): Pair<String?, String?> {
var responseText: String? = null
var errorMessage: String? = null
Expand Down Expand Up @@ -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)
Expand All @@ -187,7 +217,7 @@ internal suspend fun callMistralApi(
return Pair(responseText, errorMessage)
}

internal suspend fun callPuterApi(modelName: String, apiKey: String, chatHistory: List<Content>, inputContent: Content): Pair<String?, String?> {
internal suspend fun callPuterApi(modelName: String, apiKey: String, chatHistory: List<Content>, inputContent: Content, cancellationHandle: AiCallCancellationHandle? = null): Pair<String?, String?> {
var responseText: String? = null
var errorMessage: String? = null

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<Content>, inputContent: Content): Pair<String?, String?> {
internal suspend fun callGroqApi(modelName: String, apiKey: String, chatHistory: List<Content>, inputContent: Content, cancellationHandle: AiCallCancellationHandle? = null): Pair<String?, String?> {
var responseText: String? = null
var errorMessage: String? = null

Expand Down Expand Up @@ -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)
Expand Down
Loading