Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 117 additions & 12 deletions .github/workflows/manual.yml
Original file line number Diff line number Diff line change
@@ -1,35 +1,140 @@
name: Android Build

on:
# push:
# branches: [ main, develop ]
# pull_request:
# branches: [ main ]
push:
branches: [ human-operator, main ]
workflow_dispatch: # Ermöglicht manuelle Ausführung des Workflows

jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
app_changed: ${{ steps.changes.outputs.app }}
humanoperator_changed: ${{ steps.changes.outputs.humanoperator }}
shared_changed: ${{ steps.changes.outputs.shared }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 2 # Letzten 2 Commits holen für Diff

- name: Detect changed files
id: changes
run: |
# Bei workflow_dispatch immer alles bauen
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> $GITHUB_OUTPUT
echo "humanoperator=true" >> $GITHUB_OUTPUT
echo "shared=true" >> $GITHUB_OUTPUT
echo "Manual dispatch - building all modules"
exit 0
fi

# Geänderte Dateien im letzten Commit ermitteln
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || echo "")

# Falls kein vorheriger Commit existiert (erster Commit), alles bauen
if [ -z "$CHANGED_FILES" ]; then
echo "app=true" >> $GITHUB_OUTPUT
echo "humanoperator=true" >> $GITHUB_OUTPUT
echo "shared=true" >> $GITHUB_OUTPUT
echo "No previous commit found - building all modules"
exit 0
fi

echo "Changed files:"
echo "$CHANGED_FILES"

# Prüfen ob shared/root files geändert wurden (build.gradle, settings.gradle, etc.)
SHARED_CHANGED=false
if echo "$CHANGED_FILES" | grep -qE '^(build\.gradle|settings\.gradle|gradle\.properties|gradle/|buildSrc/)'; then
SHARED_CHANGED=true
fi

# Prüfen ob app/ Dateien geändert wurden
APP_CHANGED=false
if echo "$CHANGED_FILES" | grep -q '^app/'; then
APP_CHANGED=true
fi

# Prüfen ob humanoperator/ Dateien geändert wurden
HUMANOPERATOR_CHANGED=false
if echo "$CHANGED_FILES" | grep -q '^humanoperator/'; then
HUMANOPERATOR_CHANGED=true
fi

echo "app=$APP_CHANGED" >> $GITHUB_OUTPUT
echo "humanoperator=$HUMANOPERATOR_CHANGED" >> $GITHUB_OUTPUT
echo "shared=$SHARED_CHANGED" >> $GITHUB_OUTPUT

echo "Results: app=$APP_CHANGED, humanoperator=$HUMANOPERATOR_CHANGED, shared=$SHARED_CHANGED"

build:
needs: detect-changes
runs-on: ubuntu-latest
env:
BUILD_APP: ${{ needs.detect-changes.outputs.app_changed == 'true' || needs.detect-changes.outputs.shared_changed == 'true' }}
BUILD_HUMANOPERATOR: ${{ needs.detect-changes.outputs.humanoperator_changed == 'true' || needs.detect-changes.outputs.shared_changed == 'true' }}

steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: Set up JDK
uses: actions/setup-java@v3
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle

- 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: Decode google-services.json (humanoperator)
env:
GOOGLE_SERVICES_JSON: ${{ secrets.GOOGLE_SERVICES_JSON_HUMANOPERATOR }}
run: printf '%s' "$GOOGLE_SERVICES_JSON" > humanoperator/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 's/org.gradle.jvmargs=.*/org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m/' gradle.properties
sed -i 's/kotlin.daemon.jvmargs=.*/kotlin.daemon.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m/' gradle.properties

- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Build with Gradle
run: ./gradlew assembleRelease
- name: Build app module (debug)
if: env.BUILD_APP == 'true'
run: ./gradlew :app:assembleDebug

- name: Upload APK
- name: Build humanoperator module (debug)
if: env.BUILD_HUMANOPERATOR == 'true'
run: ./gradlew :humanoperator:assembleDebug

- name: Upload app APK
if: env.BUILD_APP == 'true'
uses: actions/upload-artifact@v4
with:
name: app-release
path: app/build/outputs/apk/release/app-release-unsigned.apk
name: app-debug
path: app/build/outputs/apk/debug/app-debug.apk

- name: Upload humanoperator APK
if: env.BUILD_HUMANOPERATOR == 'true'
uses: actions/upload-artifact@v4
with:
name: humanoperator-debug
path: humanoperator/build/outputs/apk/debug/humanoperator-debug.apk

- name: Build summary
run: |
echo "### Build Summary" >> $GITHUB_STEP_SUMMARY
echo "| Module | Built |" >> $GITHUB_STEP_SUMMARY
echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| app | ${{ env.BUILD_APP }} |" >> $GITHUB_STEP_SUMMARY
echo "| humanoperator | ${{ env.BUILD_HUMANOPERATOR }} |" >> $GITHUB_STEP_SUMMARY
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.maxTokenizationLineLength": 20000
}
18 changes: 17 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,20 @@ plugins {
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.20"
id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin")
id("kotlin-parcelize")
id("com.google.gms.google-services")
}

// Redirect build output to C: drive (NTFS) to avoid corrupted ExFAT build cache
if (System.getenv("CI") == null) {
layout.buildDirectory = file("C:/GradleBuild/app")
}

android {
namespace = "com.google.ai.sample"
compileSdk = 35

defaultConfig {
applicationId = "com.google.ai.sample"
applicationId = "io.github.android_poweruser"
minSdk = 26
targetSdk = 35
versionCode = 1
Expand Down Expand Up @@ -96,4 +102,14 @@ dependencies {

// Camera Core to potentially fix missing JNI lib issue
implementation("androidx.camera:camera-core:1.4.0")

// WebRTC
implementation("io.getstream:stream-webrtc-android:1.1.1")

// WebSocket for signaling
implementation("com.squareup.okhttp3:okhttp:4.12.0")

// Firebase
implementation(platform("com.google.firebase:firebase-bom:32.7.2"))
implementation("com.google.firebase:firebase-database")
}
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Storage permissions -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/kotlin/com/google/ai/sample/ApiKeyDialog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ fun ApiKeyDialog(
ApiProvider.GOOGLE -> "https://makersuite.google.com/app/apikey"
ApiProvider.CEREBRAS -> "https://cloud.cerebras.ai/"
ApiProvider.VERCEL -> "https://vercel.com/ai-gateway"
ApiProvider.HUMAN_EXPERT -> return@Button // No API key needed
ApiProvider.HUMAN_EXPERT -> return@Button
}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
context.startActivity(intent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import com.google.ai.client.generativeai.GenerativeModel
import com.google.ai.client.generativeai.type.generationConfig
import com.google.ai.sample.feature.live.LiveApiManager
import com.google.ai.sample.feature.multimodal.PhotoReasoningViewModel
import com.google.ai.sample.util.GenerationSettingsPreferences

// Model options
enum class ApiProvider {
Expand Down Expand Up @@ -44,24 +45,32 @@ enum class ModelOption(
"https://huggingface.co/na5h13/gemma-3n-E4B-it-litert-lm/resolve/main/gemma-3n-E4B-it-int4.litertlm?download=true",
"4.92 GB"
),
HUMAN_EXPERT("Human Expert", "human-expert", ApiProvider.HUMAN_EXPERT)
HUMAN_EXPERT("Human Expert", "human-expert", ApiProvider.HUMAN_EXPERT);

/** Whether this model supports TopK/TopP/Temperature settings */
val supportsGenerationSettings: Boolean
get() = this != HUMAN_EXPERT
}

val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(
viewModelClass: Class<T>,
extras: CreationExtras
): T {
val config = generationConfig {
temperature = 0.0f
}

// Get the application context from extras
val application = checkNotNull(extras[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY])
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()

// Load per-model generation settings
val genSettings = GenerationSettingsPreferences.loadSettings(application.applicationContext, currentModel.modelName)
val config = generationConfig {
temperature = genSettings.temperature
topP = genSettings.topP
topK = genSettings.topK
}

// Get the API key from MainActivity
val mainActivity = MainActivity.getInstance()
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()
val apiKey = if (currentModel == ModelOption.GEMMA_3N_E4B_IT || currentModel == ModelOption.HUMAN_EXPERT) {
"offline-no-key-needed" // Dummy key for offline/human expert models
} else {
Expand All @@ -75,8 +84,6 @@ val GenerativeViewModelFactory = object : ViewModelProvider.Factory {
return with(viewModelClass) {
when {
isAssignableFrom(PhotoReasoningViewModel::class.java) -> {
val currentModel = GenerativeAiViewModelFactory.getCurrentModel()

if (currentModel.modelName.contains("live")) {
// Live API models
val liveApiManager = LiveApiManager(apiKey, currentModel.modelName)
Expand Down
44 changes: 39 additions & 5 deletions app/src/main/kotlin/com/google/ai/sample/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,14 @@ class MainActivity : ComponentActivity() {
// MediaProjection
private lateinit var mediaProjectionManager: MediaProjectionManager
private lateinit var mediaProjectionLauncher: ActivityResultLauncher<Intent>
private lateinit var webRtcMediaProjectionLauncher: ActivityResultLauncher<Intent>

private var currentScreenInfoForScreenshot: String? = null

private lateinit var navController: NavHostController
private var isProcessingExplicitScreenshotRequest: Boolean = false
private var onMediaProjectionPermissionGranted: (() -> Unit)? = null
private var onWebRtcMediaProjectionResult: ((Int, Intent) -> Unit)? = null

private val screenshotRequestHandler = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Expand Down Expand Up @@ -187,15 +189,28 @@ class MainActivity : ComponentActivity() {
// This should be guaranteed by its placement in onCreate.
if (!::mediaProjectionManager.isInitialized) {
Log.e(TAG, "requestMediaProjectionPermission: mediaProjectionManager not initialized!")
// Optionally, initialize it here as a fallback, though it indicates an issue with onCreate ordering
// mediaProjectionManager = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
// Toast.makeText(this, "Error: Projection manager not ready. Please try again.", Toast.LENGTH_SHORT).show()
return
}
val intent = mediaProjectionManager.createScreenCaptureIntent()
mediaProjectionLauncher.launch(intent)
}

/**
* Request a fresh MediaProjection permission specifically for WebRTC (Human Expert).
* This does NOT start ScreenCaptureService - the result is passed directly to the callback.
*/
fun requestMediaProjectionForWebRTC(onResult: (Int, Intent) -> Unit) {
Log.d(TAG, "Requesting MediaProjection permission for WebRTC")
onWebRtcMediaProjectionResult = onResult

if (!::mediaProjectionManager.isInitialized) {
Log.e(TAG, "requestMediaProjectionForWebRTC: mediaProjectionManager not initialized!")
return
}
val intent = mediaProjectionManager.createScreenCaptureIntent()
webRtcMediaProjectionLauncher.launch(intent)
}

fun takeAdditionalScreenshot() {
if (ScreenCaptureService.isRunning()) {
Log.d(TAG, "MainActivity: Instructing ScreenCaptureService to take an additional screenshot.")
Expand Down Expand Up @@ -286,7 +301,7 @@ class MainActivity : ComponentActivity() {

when (currentTrialState) {
TrialManager.TrialState.EXPIRED_INTERNET_TIME_CONFIRMED -> {
trialInfoMessage = "Your 30-minute trial period has ended. Please subscribe to the app to continue using it."
trialInfoMessage = "Please support the development of the app so that you can continue using it \uD83C\uDF89"
showTrialInfoDialog = true
Log.d(TAG, "updateTrialState: Set message to \'$trialInfoMessage\', showTrialInfoDialog = true (EXPIRED)")
}
Expand Down Expand Up @@ -444,6 +459,10 @@ class MainActivity : ComponentActivity() {
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val shouldTakeScreenshotOnThisStart = this@MainActivity.isProcessingExplicitScreenshotRequest
Log.i(TAG, "MediaProjection permission granted. Starting ScreenCaptureService. Explicit request: $shouldTakeScreenshotOnThisStart")

// Notify ViewModel about the permission grant (for Human Expert WebRTC)
photoReasoningViewModel?.onMediaProjectionPermissionGranted(result.resultCode, result.data!!)

val serviceIntent = Intent(this, ScreenCaptureService::class.java).apply {
action = ScreenCaptureService.ACTION_START_CAPTURE
putExtra(ScreenCaptureService.EXTRA_RESULT_CODE, result.resultCode)
Expand Down Expand Up @@ -487,6 +506,21 @@ class MainActivity : ComponentActivity() {
}
}

// Separate WebRTC MediaProjection launcher - does NOT start ScreenCaptureService
webRtcMediaProjectionLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
Log.i(TAG, "WebRTC MediaProjection permission granted.")
onWebRtcMediaProjectionResult?.invoke(result.resultCode, result.data!!)
onWebRtcMediaProjectionResult = null
} else {
Log.w(TAG, "WebRTC MediaProjection permission denied.")
Toast.makeText(this, "Screen capture permission denied", Toast.LENGTH_SHORT).show()
onWebRtcMediaProjectionResult = null
}
}

// Keyboard visibility listener
val rootView = findViewById<View>(android.R.id.content)
onGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
Expand Down Expand Up @@ -1222,7 +1256,7 @@ fun TrialExpiredDialog(
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Your 7-day trial period has ended. Please subscribe to the app to continue using it.",
text = "Please support the development of the app so that you can continue using it \uD83C\uDF89",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Expand Down
Loading
Loading