diff --git a/.github/workflows/android-leap-chat-test.yml b/.github/workflows/android-leap-chat-test.yml new file mode 100644 index 0000000..7072f3a --- /dev/null +++ b/.github/workflows/android-leap-chat-test.yml @@ -0,0 +1,41 @@ +name: Android LeapChat Build +on: + push: + branches: [ main ] + paths: + - 'Android/LeapChat/**' + - '.github/workflows/android-leap-chat-test.yml' + pull_request: + branches: [ main ] + paths: + - 'Android/LeapChat/**' + - '.github/workflows/android-leap-chat-test.yml' + workflow_dispatch: + +jobs: + build-and-e2e-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + - name: Build LeapChat + run: cd Android/LeapChat && ./gradlew :app:assemble + - name: Build E2E test + run: cd Android/LeapChat && ./gradlew :app:assembleAndroidTest + - name: Run E2E test on Firebase Test Lab + run: | + echo "$SERVICE_ACCOUNT" > /tmp/service_account.json + gcloud auth activate-service-account --key-file=/tmp/service_account.json + gcloud firebase test android run --type instrumentation \ + --app Android/LeapChat/app/build/outputs/apk/debug/app-debug.apk \ + --test Android/LeapChat/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk \ + --device model=MediumPhone.arm,version=36,locale=en,orientation=portrait \ + --project liquid-leap + env: + SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + diff --git a/Android/LeapChat/app/src/androidTest/java/ai/liquid/leapchat/MainActivityTest.kt b/Android/LeapChat/app/src/androidTest/java/ai/liquid/leapchat/MainActivityTest.kt new file mode 100644 index 0000000..32a75b0 --- /dev/null +++ b/Android/LeapChat/app/src/androidTest/java/ai/liquid/leapchat/MainActivityTest.kt @@ -0,0 +1,66 @@ +package ai.liquid.leapchat + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertTextContains +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MainActivityE2EAssetTests { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + + @OptIn(ExperimentalTestApi::class) + @Test + fun testEndToEndChat() { + val modelLoadingIndicatorMatcher = hasTestTag("ModelLoadingIndicator") + val inputBoxMatcher = hasTestTag("InputBox") + val sendButtonMatcher = hasText("Send") + + // Wait for the model to be downloaded and loaded + composeTestRule.onNode(modelLoadingIndicatorMatcher).assertIsDisplayed() + composeTestRule.waitUntilDoesNotExist( + modelLoadingIndicatorMatcher, + timeoutMillis = MODEL_LOADING_TIMEOUT + ) + composeTestRule.waitUntilAtLeastOneExists(sendButtonMatcher, timeoutMillis = 5000L) + + // Send an input to the model + composeTestRule.onNode(inputBoxMatcher) + .performTextInput("How many 'r' are there in the word 'strawberry'?") + composeTestRule.onNode(sendButtonMatcher).performClick() + composeTestRule.waitUntilAtLeastOneExists( + hasTestTag("AssistantMessageView"), + timeoutMillis = 5000L + ) + composeTestRule.waitUntil(timeoutMillis = 5000L) { + composeTestRule.onNode(hasTestTag("AssistantMessageViewText").and(hasText("strawberry", substring = true))) + .isDisplayed() + } + + + // Continue the chat with a second prompt + composeTestRule.onNode(inputBoxMatcher).performTextInput("What about letter 'a'?") + composeTestRule.onNode(sendButtonMatcher).performClick() + composeTestRule.waitUntil(timeoutMillis = 5000L) { + composeTestRule.onAllNodesWithTag("AssistantMessageView") + .fetchSemanticsNodes().size == 2 + } + } + + companion object { + const val MODEL_LOADING_TIMEOUT = 5L * 60L * 1000L + } +} \ No newline at end of file diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt index 3c53f28..73f0645 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/MainActivity.kt @@ -17,7 +17,6 @@ import ai.liquid.leap.message.ChatMessageContent import ai.liquid.leap.message.MessageResponse import ai.liquid.leapchat.models.ChatMessageDisplayItem import ai.liquid.leapchat.views.ChatHistory -import android.annotation.SuppressLint import android.os.Bundle import android.util.Log import androidx.activity.ComponentActivity @@ -53,6 +52,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.MutableLiveData import androidx.lifecycle.lifecycleScope @@ -93,6 +93,10 @@ class MainActivity : ComponentActivity() { MutableLiveData(false) } + private val isToolEnabled: MutableLiveData by lazy { + MutableLiveData(false) + } + private val gson = GsonBuilder().registerLeapAdapters().create() override fun onCreate(savedInstanceState: Bundle?) { @@ -149,23 +153,22 @@ class MainActivity : ComponentActivity() { onValueChange = { userInputFieldText = it }, modifier = Modifier .padding(4.dp) - .fillMaxWidth(1.0f), + .fillMaxWidth(1.0f).testTag("InputBox"), enabled = !isInGeneration.value ) Row( horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth(1.0f) ) { - Button( - onClick = { - this@MainActivity.isInGeneration.value = true - sendText(userInputFieldText) - userInputFieldText = "" - chatHistoryFocusRequester.requestFocus() - }, - enabled = !isInGeneration.value - ) { - Text(getString(R.string.send_message_button_label)) + val isToolEnabledState by isToolEnabled.observeAsState(false) + Button(onClick = { + isToolEnabled.value = !isToolEnabledState + }) { + if (isToolEnabledState) { + Text(getString(R.string.tool_on_button_label)) + } else { + Text(getString(R.string.tool_off_button_label)) + } } Button( onClick = { @@ -184,6 +187,17 @@ class MainActivity : ComponentActivity() { ) { Text(getString(R.string.clean_history_button_label)) } + Button( + onClick = { + this@MainActivity.isInGeneration.value = true + sendText(userInputFieldText) + userInputFieldText = "" + chatHistoryFocusRequester.requestFocus() + }, + enabled = !isInGeneration.value + ) { + Text(getString(R.string.send_message_button_label)) + } } } } @@ -359,20 +373,21 @@ class MainActivity : ComponentActivity() { conversationInstance } - - conversation.registerFunction( - LeapFunction( - "compute_sum", "Compute sum of a series of numbers", listOf( - LeapFunctionParameter( - name = "values", - type = LeapFunctionParameterType.Array( - itemType = LeapFunctionParameterType.String() - ), - description = "Numbers to compute sum. Values should be represented in string." + if (isToolEnabled.value == true) { + conversation.registerFunction( + LeapFunction( + "compute_sum", "Compute sum of a series of numbers", listOf( + LeapFunctionParameter( + name = "values", + type = LeapFunctionParameterType.Array( + itemType = LeapFunctionParameterType.String() + ), + description = "Numbers to compute sum. Values should be represented in string." + ) ) ) ) - ) + } return conversation } @@ -455,8 +470,8 @@ class MainActivity : ComponentActivity() { } companion object { - const val MODEL_SLUG = "lfm2-1.2b" - const val QUANTIZATION_SLUG = "lfm2-1.2b-20250710-8da4w" + const val MODEL_SLUG = "lfm2-350m" + const val QUANTIZATION_SLUG = "lfm2-350m-20250710-8da4w" } } @@ -480,9 +495,13 @@ fun ModelLoadingIndicator( }) } } - Box(Modifier - .padding(4.dp) - .fillMaxSize(1.0f), contentAlignment = Alignment.Center) { + Box( + Modifier + .padding(4.dp) + .fillMaxSize(1.0f) + .testTag("ModelLoadingIndicator"), + contentAlignment = Alignment.Center + ) { Text(modelLoadingStatusText, style = MaterialTheme.typography.titleSmall) } -} +} \ No newline at end of file diff --git a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/AssistantMessage.kt b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/AssistantMessage.kt index 47a7af5..68d5a79 100644 --- a/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/AssistantMessage.kt +++ b/Android/LeapChat/app/src/main/java/ai/liquid/leapchat/views/AssistantMessage.kt @@ -17,6 +17,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -27,7 +28,7 @@ fun AssistantMessage( reasoningText: String?, ) { val reasoningText = reasoningText?.trim() - Row(modifier = Modifier.padding(all = 8.dp).fillMaxWidth(1.0f), horizontalArrangement = Arrangement.Absolute.Left) { + Row(modifier = Modifier.padding(all = 8.dp).fillMaxWidth(1.0f).testTag("AssistantMessageView"), horizontalArrangement = Arrangement.Absolute.Left) { Image( painter = painterResource(R.drawable.smart_toy_outline), contentDescription = "Assistant icon", @@ -43,7 +44,7 @@ fun AssistantMessage( Text(text = reasoningText, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) } Spacer(modifier = Modifier.height(4.dp)) - Text(text = text, style = MaterialTheme.typography.bodyMedium) + Text(text = text, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.testTag("AssistantMessageViewText")) } } } diff --git a/Android/LeapChat/app/src/main/res/values/strings.xml b/Android/LeapChat/app/src/main/res/values/strings.xml index ced5019..359056d 100644 --- a/Android/LeapChat/app/src/main/res/values/strings.xml +++ b/Android/LeapChat/app/src/main/res/values/strings.xml @@ -6,5 +6,6 @@ Send Clean Stop - + Tool ON + Tool OFF \ No newline at end of file diff --git a/Android/LeapChat/gradle/libs.versions.toml b/Android/LeapChat/gradle/libs.versions.toml index 4a079a9..163fc57 100644 --- a/Android/LeapChat/gradle/libs.versions.toml +++ b/Android/LeapChat/gradle/libs.versions.toml @@ -5,7 +5,7 @@ coreKtx = "1.16.0" junit = "4.13.2" junitVersion = "1.2.1" espressoCore = "3.6.1" -leapSdk = "0.4.0" +leapSdk = "0.5.0" lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" composeBom = "2025.06.01"