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
70 changes: 0 additions & 70 deletions .github/workflows/android-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
name: Android Build

on:
pull_request:
branches: [main]
schedule:
# Run nightly at midnight UTC
- cron: '0 0 * * *'
Expand Down Expand Up @@ -69,71 +67,3 @@ jobs:
name: ${{ matrix.name }}-apk
path: ${{ matrix.path }}/app/build/outputs/apk/
if-no-files-found: warn

instrumentation-test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- name: LlamaDemo
path: llm/android/LlamaDemo
env:
API_LEVEL: 34
ARCH: x86_64
EMULATOR_OPTIONS: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none

name: Instrumentation Test ${{ matrix.name }}
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

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

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ env.API_LEVEL }}-${{ env.ARCH }}

- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ env.API_LEVEL }}
arch: ${{ env.ARCH }}
force-avd-creation: false
ram-size: 16384M
emulator-options: ${{ env.EMULATOR_OPTIONS }}
disable-animations: false
working-directory: ${{ matrix.path }}
script: echo "Generated AVD snapshot for caching."

- name: Run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ env.API_LEVEL }}
arch: ${{ env.ARCH }}
force-avd-creation: false
ram-size: 6144M
emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }}
disable-animations: true
working-directory: ${{ matrix.path }}
script: |
./gradlew connectedCheck
113 changes: 113 additions & 0 deletions .github/workflows/llm-android.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.

name: LlamaDemo Android

on:
pull_request:
branches: [main]
paths:
- 'llm/android/**'
- '.github/workflows/llm-android.yml'
workflow_dispatch:
inputs:
pte_url:
description: 'URL to download model .pte file'
required: false
type: string
default: 'https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/stories110M.pte'
tokenizer_url:
description: 'URL to download tokenizer file'
required: false
type: string
default: 'https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/tokenizer.model'

permissions:
contents: read

env:
# Default URLs for pull_request trigger (workflow_dispatch inputs override these)
DEFAULT_PTE_URL: 'https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/stories110M.pte'
DEFAULT_TOKENIZER_URL: 'https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114/tokenizer.model'

jobs:
instrumentation-test:
runs-on: ubuntu-latest
env:
API_LEVEL: 34
ARCH: x86_64
EMULATOR_OPTIONS: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none

name: Instrumentation Test LlamaDemo
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

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

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4

- name: Download model files
run: |
PTE_URL="${{ inputs.pte_url || env.DEFAULT_PTE_URL }}"
TOKENIZER_URL="${{ inputs.tokenizer_url || env.DEFAULT_TOKENIZER_URL }}"

mkdir -p /tmp/llama-models
echo "Downloading model from $PTE_URL"
curl -fL -o /tmp/llama-models/model.pte "$PTE_URL"
echo "Downloading tokenizer from $TOKENIZER_URL"
curl -fL -o /tmp/llama-models/tokenizer.model "$TOKENIZER_URL"

ls -la /tmp/llama-models/

- name: AVD cache
uses: actions/cache@v4
id: avd-cache
with:
path: |
~/.android/avd/*
~/.android/adb*
key: avd-${{ env.API_LEVEL }}-${{ env.ARCH }}

- name: Create AVD and generate snapshot for caching
if: steps.avd-cache.outputs.cache-hit != 'true'
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ env.API_LEVEL }}
arch: ${{ env.ARCH }}
force-avd-creation: false
ram-size: 6144M
emulator-options: ${{ env.EMULATOR_OPTIONS }}
disable-animations: false
working-directory: llm/android/LlamaDemo
script: echo "Generated AVD snapshot for caching."

- name: Run instrumentation tests
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: ${{ env.API_LEVEL }}
arch: ${{ env.ARCH }}
force-avd-creation: false
ram-size: 6144M
emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }}
disable-animations: true
working-directory: llm/android/LlamaDemo
script: |
adb shell mkdir -p /data/local/tmp/llama/
adb push /tmp/llama-models/model.pte /data/local/tmp/llama/
adb push /tmp/llama-models/tokenizer.model /data/local/tmp/llama/
./gradlew connectedCheck -PskipModelDownload=true
59 changes: 36 additions & 23 deletions llm/android/LlamaDemo/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ plugins {
// Model files configuration for instrumentation tests
val modelFilesBaseUrl = "https://ossci-android.s3.amazonaws.com/executorch/stories/snapshot-20260114"
val deviceModelDir = "/data/local/tmp/llama"
val modelFiles = listOf(
"stories110M.pte",
"tokenizer.model"
val modelFiles = mapOf(
"stories110M.pte" to "model.pte",
"tokenizer.model" to "tokenizer.model"
)
val skipModelDownload: Boolean = (project.findProperty("skipModelDownload") as? String)?.toBoolean() ?: false

fun execCmd(vararg args: String): String {
val process = ProcessBuilder(*args)
Expand All @@ -42,6 +43,11 @@ tasks.register("pushModelFiles") {
group = "verification"

doLast {
if (skipModelDownload) {
logger.lifecycle("Skipping model download (skipModelDownload=true)")
return@doLast
}

// Check if adb is available
val adbPath = android.adbExecutable.absolutePath
val (adbCheckCode, _) = execCmdWithExitCode(adbPath, "devices")
Expand All @@ -50,8 +56,8 @@ tasks.register("pushModelFiles") {
}

// Check which files need to be pushed
val filesToPush = modelFiles.filter { fileName ->
val devicePath = "$deviceModelDir/$fileName"
val filesToPush = modelFiles.filter { (_, targetName) ->
val devicePath = "$deviceModelDir/$targetName"
val (exitCode, _) = execCmdWithExitCode(adbPath, "shell", "test -f $devicePath && echo exists")
exitCode != 0
}
Expand All @@ -61,7 +67,7 @@ tasks.register("pushModelFiles") {
return@doLast
}

logger.lifecycle("Need to push ${filesToPush.size} model file(s): ${filesToPush.joinToString(", ")}")
logger.lifecycle("Need to push ${filesToPush.size} model file(s): ${filesToPush.values.joinToString(", ")}")

// Create temp directory using mktemp
val tempDir = execCmd("mktemp", "-d")
Expand All @@ -71,45 +77,52 @@ tasks.register("pushModelFiles") {
// Create device directory
execCmd(adbPath, "shell", "mkdir -p $deviceModelDir")

for (fileName in filesToPush) {
val localPath = "$tempDir/$fileName"
val checksumPath = "$tempDir/$fileName.sha256sums"
val devicePath = "$deviceModelDir/$fileName"
for ((sourceName, targetName) in filesToPush) {
val localPath = "$tempDir/$targetName"
val checksumPath = "$tempDir/$sourceName.sha256sums"
val devicePath = "$deviceModelDir/$targetName"

// Download file
logger.lifecycle("Downloading $fileName...")
// Download file (with original name for checksum verification, then rename)
val downloadPath = "$tempDir/$sourceName"
logger.lifecycle("Downloading $sourceName...")
val (dlCode, dlOutput) = execCmdWithExitCode(
"curl", "-fL", "-o", localPath, "$modelFilesBaseUrl/$fileName"
"curl", "-fL", "-o", downloadPath, "$modelFilesBaseUrl/$sourceName"
)
if (dlCode != 0) {
throw GradleException("Failed to download $fileName: $dlOutput")
throw GradleException("Failed to download $sourceName: $dlOutput")
}

// Download and verify checksum
logger.lifecycle("Verifying checksum for $fileName...")
logger.lifecycle("Verifying checksum for $sourceName...")
val (csDownloadCode, csDownloadOutput) = execCmdWithExitCode(
"curl", "-fL", "-o", checksumPath, "$modelFilesBaseUrl/$fileName.sha256sums"
"curl", "-fL", "-o", checksumPath, "$modelFilesBaseUrl/$sourceName.sha256sums"
)
if (csDownloadCode != 0) {
throw GradleException("Failed to download checksum for $fileName: $csDownloadOutput")
throw GradleException("Failed to download checksum for $sourceName: $csDownloadOutput")
}

// Verify checksum (run sha256sum in the temp directory)
val (verifyCode, verifyOutput) = execCmdWithExitCode(
"bash", "-c", "cd $tempDir && sha256sum -c $fileName.sha256sums"
"bash", "-c", "cd $tempDir && sha256sum -c $sourceName.sha256sums"
)
if (verifyCode != 0) {
throw GradleException("Checksum verification failed for $fileName: $verifyOutput")
throw GradleException("Checksum verification failed for $sourceName: $verifyOutput")
}
logger.lifecycle("Checksum verified for $sourceName")

// Rename file if needed
if (sourceName != targetName) {
execCmd("mv", downloadPath, localPath)
logger.lifecycle("Renamed $sourceName to $targetName")
}
logger.lifecycle("Checksum verified for $fileName")

// Push to device
logger.lifecycle("Pushing $fileName to device...")
logger.lifecycle("Pushing $targetName to device...")
val (pushCode, pushOutput) = execCmdWithExitCode(adbPath, "push", localPath, devicePath)
if (pushCode != 0) {
throw GradleException("Failed to push $fileName to device: $pushOutput")
throw GradleException("Failed to push $targetName to device: $pushOutput")
}
logger.lifecycle("Successfully pushed $fileName")
logger.lifecycle("Successfully pushed $targetName")
}
} finally {
// Clean up temp directory
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ public class SanityCheck implements LlmCallback {

private static final String RESOURCE_PATH = "/data/local/tmp/llama/";
private static final String TOKENIZER_PATH = "tokenizer.model";
private static final String MODEL_PATH = "stories110M.pte";
private static final String MODEL_PATH = "model.pte";

private final List<String> results = new ArrayList<>();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ public void clearSharedPreferences() {
* 1. Dismiss the "Please Select a Model" dialog
* 2. Click settings button
* 3. Verify model path and tokenizer path show default "no selection" text
* 4. Click model selection, select stories110M.pte
* 4. Click model selection, select model.pte
* 5. Click tokenizer selection, select tokenizer.model
* 6. Click load model button
*/
Expand All @@ -92,10 +92,10 @@ public void testModelLoadingWorkflow() throws Exception {
onView(withId(R.id.modelTextView)).check(matches(withText("no model selected")));
onView(withId(R.id.tokenizerTextView)).check(matches(withText("no tokenizer selected")));

// Step 3: Click model selection button and select stories110M.pte
// Step 3: Click model selection button and select model.pte
onView(withId(R.id.modelImageButton)).perform(click());
// Select the model file containing "stories110M.pte"
onData(hasToString(containsString("stories110M.pte"))).inRoot(isDialog()).perform(click());
// Select the model file containing "model.pte"
onData(hasToString(containsString("model.pte"))).inRoot(isDialog()).perform(click());

// Step 4: Click tokenizer selection button and select tokenizer.model
onView(withId(R.id.tokenizerImageButton)).perform(click());
Expand Down Expand Up @@ -139,10 +139,10 @@ public void testSendMessageAndReceiveResponse() throws Exception {
// Verify load button is initially disabled (no model/tokenizer selected)
onView(withId(R.id.loadModelButton)).check(matches(not(isEnabled())));

// Select model - choose stories110M.pte
// Select model - choose model.pte
onView(withId(R.id.modelImageButton)).perform(click());
Thread.sleep(300); // Wait for dialog to appear
onData(hasToString(containsString("stories110M.pte"))).inRoot(isDialog()).perform(click());
onData(hasToString(containsString("model.pte"))).inRoot(isDialog()).perform(click());
Thread.sleep(300); // Wait for dialog to dismiss and UI to update

// Select tokenizer - choose tokenizer.model
Expand Down