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
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import org.junit.runner.RunWith
* - Push a tokenizer file (.bin, .json, or .model) to /data/local/tmp/llama/
*
* This test validates:
* - Welcome screen navigation
* - Settings screen shows empty model/tokenizer paths by default
* - File selection dialogs display pushed files
* - User can select model and tokenizer files
Expand All @@ -65,7 +66,7 @@ class UIWorkflowTest {
}

@get:Rule
val composeTestRule = createAndroidComposeRule<MainActivity>()
val composeTestRule = createAndroidComposeRule<WelcomeActivity>()

private lateinit var modelFile: String
private lateinit var tokenizerFile: String
Expand All @@ -88,51 +89,57 @@ class UIWorkflowTest {
}

/**
* Clears chat history via the Settings UI.
* Clears chat history via the App Settings UI.
* This ensures each test starts with a clean state.
* Must be called from the Welcome screen.
*/
private fun clearChatHistory() {
composeTestRule.waitForIdle()

// Go to settings
// Go to App Settings from Welcome screen
try {
composeTestRule.onNodeWithContentDescription("Settings").performClick()
composeTestRule.onNodeWithText("App Settings").performClick()
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithText("Clear Chat History")
composeTestRule.onAllNodesWithText("Clear Conversation History")
.fetchSemanticsNodes().isNotEmpty()
}
} catch (e: Exception) {
Log.d(TAG, "Could not open settings to clear history: ${e.message}")
Log.d(TAG, "Could not open App Settings to clear history: ${e.message}")
return
}

// Click Clear Chat History button (clears immediately, no confirmation dialog)
// Click Clear Conversation History button
try {
composeTestRule.onNodeWithText("Clear Chat History").performClick()
composeTestRule.onNodeWithText("Clear Conversation History").performClick()
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithText("Clear").fetchSemanticsNodes().isNotEmpty()
}
// Confirm in dialog
composeTestRule.onNodeWithText("Clear").performClick()
composeTestRule.waitForIdle()
Log.i(TAG, "Chat history cleared")
} catch (e: Exception) {
Log.d(TAG, "Could not clear chat history: ${e.message}")
}

// Go back to chat screen using system back
// Go back to Welcome screen using back button
try {
Espresso.pressBack()
composeTestRule.onNodeWithContentDescription("Back").performClick()
composeTestRule.waitForIdle()
} catch (e: Exception) {
Log.d(TAG, "Could not press back after clearing history: ${e.message}")
}
}

/**
* Navigates to settings and selects model/tokenizer files.
* Navigates from Welcome screen to settings and selects model/tokenizer files.
* Returns true if successful.
*/
private fun loadModel(): Boolean {
// Click settings button
composeTestRule.onNodeWithContentDescription("Settings").performClick()
// Click "Load local model" card on Welcome screen
composeTestRule.onNodeWithText("Load local model").performClick()
composeTestRule.waitUntil(timeoutMillis = 5001) {
composeTestRule.onAllNodesWithText("Settings").fetchSemanticsNodes().isNotEmpty()
composeTestRule.onAllNodesWithText("Select a Model").fetchSemanticsNodes().isNotEmpty()
}

// Click model row to open model selection dialog
Expand Down Expand Up @@ -266,7 +273,7 @@ class UIWorkflowTest {

/**
* Tests the complete model loading workflow:
* 1. Click settings button
* 1. Click "Load Local LLM Model" card on Welcome screen
* 2. Verify model path and tokenizer path show default "no selection" text
* 3. Click model selection, select model.pte
* 4. Click tokenizer selection, select tokenizer.model
Expand All @@ -276,14 +283,14 @@ class UIWorkflowTest {
fun testModelLoadingWorkflow() {
composeTestRule.waitForIdle()

// Click settings button
composeTestRule.onNodeWithContentDescription("Settings").performClick()
// Click "Load local model" card on Welcome screen
composeTestRule.onNodeWithText("Load local model").performClick()
composeTestRule.waitUntil(timeoutMillis = 5005) {
composeTestRule.onAllNodesWithText("Settings").fetchSemanticsNodes().isNotEmpty()
composeTestRule.onAllNodesWithText("Select a Model").fetchSemanticsNodes().isNotEmpty()
}

// Verify we're in settings
composeTestRule.onNodeWithText("Settings").assertIsDisplayed()
composeTestRule.onNodeWithText("Select a Model").assertIsDisplayed()
composeTestRule.onNodeWithText("Load Model").assertIsDisplayed()
composeTestRule.onNodeWithText("no model selected").assertIsDisplayed()
composeTestRule.onNodeWithText("no tokenizer selected").assertIsDisplayed()
Expand Down Expand Up @@ -488,14 +495,14 @@ class UIWorkflowTest {
fun testNoFilesInDirectory() {
composeTestRule.waitForIdle()

// Go to settings
composeTestRule.onNodeWithContentDescription("Settings").performClick()
// Go to settings from Welcome screen
composeTestRule.onNodeWithText("Load local model").performClick()
composeTestRule.waitUntil(timeoutMillis = 5011) {
composeTestRule.onAllNodesWithText("Settings").fetchSemanticsNodes().isNotEmpty()
composeTestRule.onAllNodesWithText("Select a Model").fetchSemanticsNodes().isNotEmpty()
}

// Verify settings screen
composeTestRule.onNodeWithText("Settings").assertIsDisplayed()
composeTestRule.onNodeWithText("Select a Model").assertIsDisplayed()

// Click model selection
composeTestRule.onNodeWithText("Model").performClick()
Expand Down Expand Up @@ -523,10 +530,10 @@ class UIWorkflowTest {
fun testCancelFileSelection() {
composeTestRule.waitForIdle()

// Go to settings
composeTestRule.onNodeWithContentDescription("Settings").performClick()
// Go to settings from Welcome screen
composeTestRule.onNodeWithText("Load local model").performClick()
composeTestRule.waitUntil(timeoutMillis = 5013) {
composeTestRule.onAllNodesWithText("Settings").fetchSemanticsNodes().isNotEmpty()
composeTestRule.onAllNodesWithText("Select a Model").fetchSemanticsNodes().isNotEmpty()
}

// Verify initial state
Expand Down Expand Up @@ -574,10 +581,10 @@ class UIWorkflowTest {
fun testLoadButtonDisabledState() {
composeTestRule.waitForIdle()

// Go to settings
composeTestRule.onNodeWithContentDescription("Settings").performClick()
// Go to settings from Welcome screen
composeTestRule.onNodeWithText("Load local model").performClick()
composeTestRule.waitUntil(timeoutMillis = 5018) {
composeTestRule.onAllNodesWithText("Settings").fetchSemanticsNodes().isNotEmpty()
composeTestRule.onAllNodesWithText("Select a Model").fetchSemanticsNodes().isNotEmpty()
}

// Verify load button is initially disabled
Expand Down Expand Up @@ -789,4 +796,53 @@ class UIWorkflowTest {
Log.i(TAG, "Media buttons not present - might be MediaTek backend")
}
}

/**
* Tests Welcome screen displays and navigation works correctly.
*/
@Test
fun testWelcomeScreenNavigation() {
composeTestRule.waitForIdle()

// Verify Welcome screen elements are displayed
composeTestRule.onNodeWithText("ExecuTorch Llama Demo").assertIsDisplayed()
composeTestRule.onNodeWithText("Welcome to ExecuTorch Llama Demo").assertIsDisplayed()
composeTestRule.onNodeWithText("Load local model").assertIsDisplayed()
composeTestRule.onNodeWithText("App Settings").assertIsDisplayed()

// Test navigation to App Settings
composeTestRule.onNodeWithText("App Settings").performClick()
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithText("App Settings", useUnmergedTree = true)
.fetchSemanticsNodes().size >= 1
}

// Verify App Settings screen
composeTestRule.onNodeWithText("Appearance").assertIsDisplayed()
composeTestRule.onNodeWithText("Theme").assertIsDisplayed()
composeTestRule.onNodeWithText("Clear Conversation History").assertIsDisplayed()

// Go back to Welcome screen
composeTestRule.onNodeWithContentDescription("Back").performClick()
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithText("ExecuTorch Llama Demo").fetchSemanticsNodes().isNotEmpty()
}

// Verify we're back on Welcome screen
composeTestRule.onNodeWithText("ExecuTorch Llama Demo").assertIsDisplayed()
composeTestRule.onNodeWithText("Load local model").assertIsDisplayed()

// Test navigation to Model Settings
composeTestRule.onNodeWithText("Load local model").performClick()
composeTestRule.waitUntil(timeoutMillis = 3000) {
composeTestRule.onAllNodesWithText("Select a Model").fetchSemanticsNodes().isNotEmpty()
}

// Verify Model Settings screen
composeTestRule.onNodeWithText("Select a Model").assertIsDisplayed()
composeTestRule.onNodeWithText("Backend").assertIsDisplayed()
composeTestRule.onNodeWithText("Load Model").assertIsDisplayed()

Log.i(TAG, "Welcome screen navigation test completed successfully")
}
}
22 changes: 15 additions & 7 deletions llm/android/LlamaDemo/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,21 @@
android:name=".LogsActivity"
android:exported="false" />
<activity
android:name=".SettingsActivity"
android:name=".ModelSettingsActivity"
android:exported="false" />
<activity
android:name=".AppSettingsActivity"
android:exported="false" />
<activity
android:name=".WelcomeActivity"
android:exported="true"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<uses-native-library
android:name="libcdsprpc.so"
Expand Down Expand Up @@ -55,15 +68,10 @@

<activity
android:name=".MainActivity"
android:exported="true"
android:exported="false"
android:label="@string/app_name"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.MAIN" />

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/*
* 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.
*/

package com.example.executorchllamademo

import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
import com.example.executorchllamademo.ui.screens.AppSettingsScreen
import com.example.executorchllamademo.ui.theme.LlamaDemoTheme

class AppSettingsActivity : ComponentActivity() {

private var appearanceMode by mutableStateOf(AppearanceMode.SYSTEM)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

if (Build.VERSION.SDK_INT >= 21) {
window.statusBarColor = ContextCompat.getColor(this, R.color.status_bar)
window.navigationBarColor = ContextCompat.getColor(this, R.color.nav_bar)
}

loadAppearanceMode()

setContent {
val isDarkTheme = when (appearanceMode) {
AppearanceMode.LIGHT -> false
AppearanceMode.DARK -> true
AppearanceMode.SYSTEM -> isSystemInDarkTheme()
}

LlamaDemoTheme(darkTheme = isDarkTheme) {
Surface(modifier = Modifier.fillMaxSize()) {
AppSettingsScreen(
onBackPressed = { finish() },
onAppearanceChanged = { mode ->
appearanceMode = mode
}
)
}
}
}
}

private fun loadAppearanceMode() {
val prefs = DemoSharedPreferences(this)
appearanceMode = prefs.getAppSettings().appearanceMode
}

override fun onResume() {
super.onResume()
loadAppearanceMode()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ class LogsActivity : ComponentActivity() {

LlamaDemoTheme(darkTheme = isDarkTheme) {
Surface(modifier = Modifier.fillMaxSize()) {
LogsScreen()
LogsScreen(
onBackClick = { finish() }
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
package com.example.executorchllamademo

import android.Manifest
import android.app.AlertDialog
import android.content.ContentValues
import android.content.Intent
import android.content.pm.PackageManager
Expand Down Expand Up @@ -81,8 +80,12 @@ class MainActivity : ComponentActivity() {

ChatScreen(
viewModel = viewModel,
onBackClick = {
startActivity(Intent(this@MainActivity, WelcomeActivity::class.java))
finish()
},
onSettingsClick = {
startActivity(Intent(this@MainActivity, SettingsActivity::class.java))
startActivity(Intent(this@MainActivity, ModelSettingsActivity::class.java))
},
onLogsClick = {
startActivity(Intent(this@MainActivity, LogsActivity::class.java))
Expand All @@ -103,8 +106,9 @@ class MainActivity : ComponentActivity() {
launchCamera()
}
},
onAudioClick = { _ ->
showAudioFileSelector()
audioFiles = ModelSettingsActivity.listLocalFile("/data/local/tmp/audio/", arrayOf(".bin")).toList(),
onAudioFileSelected = { audioFile ->
chatViewModel?.setAudioFile(audioFile)
}
)
}
Expand Down Expand Up @@ -164,18 +168,6 @@ class MainActivity : ComponentActivity() {
cameraImageUri?.let { cameraRoll.launch(it) }
}

private fun showAudioFileSelector() {
val audioFiles = SettingsActivity.listLocalFile("/data/local/tmp/audio/", arrayOf(".bin"))
AlertDialog.Builder(this)
.setTitle("Select audio feature path")
.setSingleChoiceItems(audioFiles, -1) { dialog, item ->
chatViewModel?.setAudioFile(audioFiles[item])
dialog.dismiss()
}
.create()
.show()
}

private fun loadAppearanceMode() {
val prefs = DemoSharedPreferences(this)
appearanceMode = prefs.getAppSettings().appearanceMode
Expand Down
Loading