AI/ML

How to create a chatbot with Firebase Firestore and PaLM on Android

A simple solution for integrating Cloud Firestore with PaLM API on Android using Jetpack Compose

Alex Mamo
Firebase Developers
8 min readJul 28, 2023

--

I recently wrote an article called:

But since now Bard is also available in Europe, I decided to write this article to show you how to create your own Android app that interacts with PaLM (🙏Peter Friese). You will be able to ask PaLM anything you want and you will get the answers almost instantly.

So what is PaLM?

Let’s try to make an experiment, let’s first build the app and then ask PaLM itself what PaLM is :) So we’ll see the answer that PaLM provides a bit later.

What we will build?

We’ll build an Android app in which we’ll be able to interact with PaLM using a Firebase Extension called Chatbot with PaLM API. The app is composed of two screens. The first screen consists of a single TextField where we are asked to type a name and the second one that consists of a list of previously asked questions, along with their answers. There is also a TextField present where we can write a question that can be sent to PaLM. The app looks like this:

Before going forward with the code, there is one thing that we should note. By the time I’m writing this article, to be able to use the Chatbot with PaLM API Extension, you have to ensure that you have already signed up for the waitlist and have been approved before actually installing it. Once you have been approved, also make sure to activate the Generative Language API in the Google Cloud Console. When it comes to selecting the Cloud Functions location, I choose Iowa (us-central1) and it worked perfectly fine.

The installation of the extension is very simple and it only takes a couple of minutes. During the installation, you will be asked to provide a Firestore collection path for storing the conversation history. There is also one very important feature that is worth mentioning, which is that the collection path also supports wildcards. This means that you can trigger the extension on multiple collections, each with its own separate conversation history. This is useful if you want to create separate conversations for different users or support multiple chat sessions. That also means that is recommended to implement Firebase Authentication (with the respective Security Rules set up) so each user can have their own private questions and answers. There are multiple types of authentication in Firebase but here are some examples that can help you can authenticate your users anonymously, using email and password, or using a provider such as Google. To keep things simple, in this example, we’ll only use a simple name that is set by the user on the first screen.

Right after we type a name, we can hit Open Chat and we can write a question. When we hit Send the question is saved in a document in a Firestore collection called history. The document looks like this:

As you can see it contains only two fields, a createdBy field which contains a name, and a field called prompt that holds the actual question. As soon as a new document is added to the history collection, a Cloud Function will fire and it will update the document twice. Once by adding the createTime field that holds a timestamp and a PROCESSING state which will be visible until the operation for getting the response is complete:

And once with a COMPLETED state that indicates that the answer is written inside the document in a field called response:

Now, let’s go ahead and create a clean architecture Android app that will be able to send questions to PaLM and display the corresponding answers.

Before writing code, make sure you have the following versions for the dependencies in the build.gradle (Project file):

buildscript {
ext {
gradle_version = '8.0.2'
kotlin_version = '1.8.22'
google_services_version = '4.3.15'
compose_bom_version = '2023.05.01'
compose_version = '1.4.8'
hilt_version = '2.46.1'
hilt_navigation_compose_version = '1.1.0-alpha01'
firebase_bom_version = '32.1.1'
}
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath "com.google.gms:google-services:$google_services_version"
classpath "com.google.dagger:hilt-android-gradle-plugin:$hilt_version"
}
}

plugins {
id 'com.android.application' version "${gradle_version}" apply false
id 'com.android.library' version "${gradle_version}" apply false
id 'org.jetbrains.kotlin.android' version "${kotlin_version}" apply false
}

task clean(type: Delete) {
delete rootProject.buildDir
}

And the following dependencies in the build.gradle (Module file):

dependencies {
//Compose
implementation platform("androidx.compose:compose-bom:$compose_bom_version")
implementation "androidx.compose.material:material"
//Hilt
implementation "com.google.dagger:hilt-android:$hilt_version"
kapt "com.google.dagger:hilt-android-compiler:$hilt_version"
//Hilt Navigation Compose
implementation "androidx.hilt:hilt-navigation-compose:$hilt_navigation_compose_version"
//Firebase
implementation platform("com.google.firebase:firebase-bom:$firebase_bom_version")
implementation "com.google.firebase:firebase-firestore-ktx"
}

Going forward with the Android code, we have a single activity called MainActivity where we create the NavGraph and set the SoftInputMode:

@AndroidEntryPoint
@ExperimentalComposeUiApi
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setSoftInputMode()
setContent {
NavGraph(
navController = rememberNavController()
)
}
}

@Suppress("DEPRECATION")
private fun setSoftInputMode() = window.setSoftInputMode(SOFT_INPUT_ADJUST_RESIZE)
}

The NavGraph looks like this:

@Composable
@ExperimentalComposeUiApi
fun NavGraph(
navController: NavHostController
) {
NavHost(
navController = navController,
startDestination = RequestNameScreen.route
) {
composable(
route = RequestNameScreen.route
) {
RequestNameScreen(
navigateToChatScreen = { name ->
navController.navigate("${ChatScreen.route}/${name}")
}
)
}

composable(
route = "${ChatScreen.route}/{$NAME}",
arguments = mutableStateListOf(
navArgument(NAME) {
type = NavType.StringType
}
)
) { backStackEntry ->
val name = backStackEntry.arguments?.getString(NAME) ?: EMPTY_STRING
ChatScreen(
name = name,
navigateBack = {
navController.popBackStack()
}
)
}
}
}

The RequestNameScreen is very simple:

@Composable
@ExperimentalComposeUiApi
fun RequestNameScreen(
navigateToChatScreen: (name: String) -> Unit
) {
Scaffold(
topBar = {
RequestNameTopBar()
},
content = { padding ->
RequestNameContent(
padding = padding,
navigateToChatScreen = navigateToChatScreen
)
}
)
}

And as said before it only contains a TextField and a Button:

@Composable
@ExperimentalComposeUiApi
fun RequestNameContent(
padding: PaddingValues,
navigateToChatScreen: (name: String) -> Unit
) {
var name by remember { mutableStateOf(EMPTY_STRING) }
val context = LocalContext.current

Column(
modifier = Modifier.fillMaxSize().padding(padding),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
val focusRequester = FocusRequester()

TextField(
value = name,
onValueChange = { newName ->
name = newName
},
placeholder = {
Text(
text = NAME_PLACEHOLDER
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text
),
modifier = Modifier.focusRequester(focusRequester)
)
LaunchedEffect(Unit) {
coroutineContext.job.invokeOnCompletion {
focusRequester.requestFocus()
}
}
Spacer(
modifier = Modifier.height(8.dp)
)
Button(
onClick = {
if (name.isNotEmpty()) {
navigateToChatScreen(name)
} else {
showMessage(context, EMPTY_NAME_MESSAGE)
}
}
) {
Text(
text = OPEN_CHAT,
fontSize = 15.sp
)
}
}
}

When we press the Button, we get the user input and navigate further to the ChatScreen:

@Composable
fun ChatScreen(
viewModel: ChatViewModel = hiltViewModel(),
name: String,
navigateBack: () -> Unit
) {
Scaffold(
topBar = {
ChatTopBar(
navigateBack = navigateBack
)
},
content = { padding ->
Questions(
chatContent = { questions ->
ChatContent(
padding = padding,
questions = questions,
sendQuestion = { question ->
viewModel.sendQuestion(name, question)
}
)
}
)
}
)
SendQuestion()
}

The content of this screen is composed of a LazyColumn that holds the questions and the answers, a TextField to write the question to PaLM, and a Button:

@Composable
fun ChatContent(
padding: PaddingValues,
questions: Questions,
sendQuestion: (question: String) -> Unit
) {
var question by remember { mutableStateOf(EMPTY_STRING) }
val context = LocalContext.current
val focusRequester = FocusRequester()

Column(
modifier = Modifier.fillMaxSize().padding(padding)
) {
if (questions.isNotEmpty()) {
LazyColumn(
modifier = Modifier.weight(1f).padding(8.dp)
) {
items(
items = questions
) { question ->
question.apply {
prompt?.let {
Text(
modifier = Modifier.padding(4.dp),
text = "${question.createBy}: ${question.prompt}",
fontSize = 15.sp
)
}
response?.let {
Text(
modifier = Modifier.padding(4.dp),
text = "$PALM: ${question.response}",
color = colorResource(R.color.teal_700)
)
} ?: kotlin.run {
ThreeDots()
}
}
}
}
} else {
Box(
modifier = Modifier.fillMaxWidth().weight(1f).padding(8.dp)
) {
Text(
modifier = Modifier.align(Alignment.Center),
text = NO_QUESTIONS_MESSAGE,
fontSize = 15.sp,
textDecoration = TextDecoration.Underline
)
}
}
Row(
modifier = Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextField(
value = question,
onValueChange = { newQuestion ->
question = newQuestion
},
placeholder = {
Text(
text = QUESTION_PLACEHOLDER
)
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text
),
modifier = Modifier.weight(1f).padding(
end = 4.dp
).focusRequester(focusRequester)
)
Button(
modifier = Modifier.padding(
start = 4.dp
),
onClick = {
if (question.isNotEmpty()) {
sendQuestion(question)
question = EMPTY_STRING
} else {
showMessage(context, EMPTY_QUESTION_MESSAGE)
}
}
) {
Text(
text = SEND,
fontSize = 15.sp
)
}
}

LaunchedEffect(Unit) {
coroutineContext.job.invokeOnCompletion {
focusRequester.requestFocus()
}
}
}
}

When we press the Button we actually call the sendQuestion() function that exists inside the ChatViewModel:

@HiltViewModel
class ChatViewModel @Inject constructor(
private val repo: ChatRepository
): ViewModel() {
var questionsResponse by mutableStateOf<QuestionsResponse>(Loading)
private set
var sendQuestionResponse by mutableStateOf<SendQuestionResponse>(Success(false))
private set

init {
getQuestions()
}

private fun getQuestions() = viewModelScope.launch {
repo.getQuestionsFromFirestore().collect { response ->
questionsResponse = response
}
}

fun sendQuestion(name: String, question: String) = viewModelScope.launch {
sendQuestionResponse = Loading
sendQuestionResponse = repo.sendQuestionToFirestore(name, question)
}
}

Please notice that in this class we also have an init block that calls the getQuestions() function that collects the data from Firestore. Inside the sendQuestion() function we call the sendQuestionToFirestore() function that exists inside the ChatRepository interface:

typealias Questions = List<Question>
typealias QuestionsResponse = Response<Questions>
typealias SendQuestionResponse = Response<Boolean>

interface ChatRepository {
fun getQuestionsFromFirestore(): Flow<QuestionsResponse>

suspend fun sendQuestionToFirestore(name: String, question: String): SendQuestionResponse
}

The implementations of these functions exist in the ChatRepositoryImpl class:

@Singleton
class ChatRepositoryImpl @Inject constructor(
private val questionsRef: CollectionReference
) : ChatRepository {
override fun getQuestionsFromFirestore() = callbackFlow {
val snapshotListener = questionsRef.orderBy(CREATE_TIME).addSnapshotListener { snapshot, e ->
val questionsResponse = if (snapshot != null) {
val questions = snapshot.toObjects(Question::class.java)
Success(questions)
} else {
Failure(e)
}
trySend(questionsResponse)
}
awaitClose {
snapshotListener.remove()
}
}

override suspend fun sendQuestionToFirestore(name: String, question: String) = try {
questionsRef.add(mapOf(
CREATE_BY to name,
PROMPT to question
)).await()
Success(true)
} catch (e: Exception) {
Failure(e)
}
}

And that’s pretty much all of it. Now we can ask PaLM any question we want in our own Android application.

Finally, let’s also answer the question that remained unanswered, “What is PaLM?”. So according to Google PaLM:

Google PaLM (Pathways Language Model) is a large language model from Google AI. It was trained on a massive dataset of text and code, and it can generate text, translate languages, write different kinds of creative content, answer your questions, and more.

Conclusion

That’s the simplest solution in which we can create our own Android app that connects to PaLM using Cloud Firestore and Chatbot with PaLM API Firebase Extension.

I hope you found this article useful and if you have any questions regarding this topic, feel free and leave a comment in the section below.

Thanks for staying with me until the end. You can find the full source code here:

You can also see it on Youtube:

#BetterTogether 🔥

--

--