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
41 changes: 41 additions & 0 deletions .github/workflows/android-leap-chat-test.yml
Original file line number Diff line number Diff line change
@@ -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
Copy link

Copilot AI Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The service account key is written to a temporary file without proper cleanup. Consider using stdin for authentication or ensure the file is securely deleted after use.

Suggested change
gcloud auth activate-service-account --key-file=/tmp/service_account.json
gcloud auth activate-service-account --key-file=/tmp/service_account.json
rm /tmp/service_account.json

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessary as the environment will be cleaned up by Github Action runners.

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 }}

Original file line number Diff line number Diff line change
@@ -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<MainActivity>()


@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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -93,6 +93,10 @@ class MainActivity : ComponentActivity() {
MutableLiveData<Boolean>(false)
}

private val isToolEnabled: MutableLiveData<Boolean> by lazy {
MutableLiveData<Boolean>(false)
}

private val gson = GsonBuilder().registerLeapAdapters().create()

override fun onCreate(savedInstanceState: Bundle?) {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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))
}
}
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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"
Comment on lines +473 to +474
Copy link

Copilot AI Sep 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Switching from lfm2-1.2b to lfm2-350m represents a significant reduction in model size (1.2B to 350M parameters). While this improves performance and reduces resource usage, ensure this change aligns with the required model capabilities for the application.

Suggested change
const val MODEL_SLUG = "lfm2-350m"
const val QUANTIZATION_SLUG = "lfm2-350m-20250710-8da4w"
/**
* Model slug for LeapChat. Default is "lfm2-350m" (350M parameters).
* Note: Switching from a larger model (e.g., "lfm2-1.2b") to "lfm2-350m" reduces resource usage
* but may impact language understanding and generation quality. Ensure this aligns with your application's needs.
* To use a different model, set the value via configuration or environment variable if supported.
*/
val MODEL_SLUG: String = System.getenv("LEAPCHAT_MODEL_SLUG") ?: "lfm2-350m"
/**
* Quantization slug for the selected model.
* Update this value if you change the model slug.
*/
val QUANTIZATION_SLUG: String = System.getenv("LEAPCHAT_QUANTIZATION_SLUG") ?: "lfm2-350m-20250710-8da4w"

Copilot uses AI. Check for mistakes.
}
}

Expand All @@ -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)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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"))
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion Android/LeapChat/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@
<string name="send_message_button_label">Send</string>
<string name="clean_history_button_label">Clean</string>
<string name="stop_generation_button_label">Stop</string>

<string name="tool_on_button_label">Tool ON</string>
<string name="tool_off_button_label">Tool OFF</string>
</resources>
2 changes: 1 addition & 1 deletion Android/LeapChat/gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down