Skip to content
6 changes: 6 additions & 0 deletions packages/firebase_ai/firebase_ai/android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,9 @@ android {
disable 'InvalidPackage'
}
}

dependencies {
implementation "com.google.mlkit:genai-prompt:1.0.0-beta2"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
}

Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
ext {
compileSdk=34
minSdk=23
minSdk=26
targetSdk=34
javaVersion = JavaVersion.toVersion(17)
androidGradlePluginVersion = '8.3.0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ class FirebaseAIPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
context = binding.applicationContext
channel = MethodChannel(binding.binaryMessenger, "plugins.flutter.io/firebase_ai")
channel.setMethodCallHandler(this)

LocalAIApi.setUp(binding.binaryMessenger, LocalAIImpl())

val eventChannel = io.flutter.plugin.common.EventChannel(binding.binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.stream")
eventChannel.setStreamHandler(LocalAIStreamHandler.shared)
}

override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
Expand Down Expand Up @@ -99,3 +104,26 @@ class FirebaseAIPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private const val TAG = "FirebaseAIPlugin"
}
}

class LocalAIStreamHandler : io.flutter.plugin.common.EventChannel.StreamHandler {
companion object {
val shared = LocalAIStreamHandler()
}
private var eventSink: io.flutter.plugin.common.EventChannel.EventSink? = null

override fun onListen(arguments: Any?, events: io.flutter.plugin.common.EventChannel.EventSink?) {
eventSink = events
}

override fun onCancel(arguments: Any?) {
eventSink = null
}

fun sendEvent(event: String) {
eventSink?.success(event)
}

fun closeStream() {
eventSink?.endOfStream()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass")

package io.flutter.plugins.firebase.ai

import android.util.Log
import io.flutter.plugin.common.BasicMessageChannel
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MessageCodec
import io.flutter.plugin.common.StandardMethodCodec
import io.flutter.plugin.common.StandardMessageCodec
import java.io.ByteArrayOutputStream
import java.nio.ByteBuffer
private object GeneratedLocalAIPigeonUtils {

fun wrapResult(result: Any?): List<Any?> {
return listOf(result)
}

fun wrapError(exception: Throwable): List<Any?> {
return if (exception is FlutterError) {
listOf(
exception.code,
exception.message,
exception.details
)
} else {
listOf(
exception.javaClass.simpleName,
exception.toString(),
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
)
}
}
}

/**
* Error class for passing custom error details to Flutter via a thrown PlatformException.
* @property code The error code.
* @property message The error message.
* @property details The error details. Must be a datatype supported by the api codec.
*/
class FlutterError (
val code: String,
override val message: String? = null,
val details: Any? = null
) : RuntimeException()
private open class GeneratedLocalAIPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return super.readValueOfType(type, buffer)
}
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
super.writeValue(stream, value)
}
}


/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface LocalAIApi {
fun isAvailable(callback: (Result<Boolean>) -> Unit)
fun generateContent(prompt: String, callback: (Result<String>) -> Unit)
fun warmup(callback: (Result<Unit>) -> Unit)
fun startStreaming(prompt: String, callback: (Result<Unit>) -> Unit)

companion object {
/** The codec used by LocalAIApi. */
val codec: MessageCodec<Any?> by lazy {
GeneratedLocalAIPigeonCodec()
}
/** Sets up an instance of `LocalAIApi` to handle messages through the `binaryMessenger`. */
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: LocalAIApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.isAvailable$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.isAvailable{ result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeneratedLocalAIPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeneratedLocalAIPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.generateContent$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val promptArg = args[0] as String
api.generateContent(promptArg) { result: Result<String> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeneratedLocalAIPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(GeneratedLocalAIPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.warmup$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.warmup{ result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeneratedLocalAIPigeonUtils.wrapError(error))
} else {
reply.reply(GeneratedLocalAIPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.firebase_ai.LocalAIApi.startStreaming$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val promptArg = args[0] as String
api.startStreaming(promptArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(GeneratedLocalAIPigeonUtils.wrapError(error))
} else {
reply.reply(GeneratedLocalAIPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package io.flutter.plugins.firebase.ai

import com.google.mlkit.genai.prompt.Generation
import com.google.mlkit.genai.prompt.GenerateContentRequest
import com.google.mlkit.genai.prompt.TextPart
import com.google.mlkit.genai.common.FeatureStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.catch

class LocalAIImpl : LocalAIApi {
private val coroutineScope = CoroutineScope(Dispatchers.Main)
private val mlkitModel by lazy { Generation.getClient() }

override fun isAvailable(callback: (Result<Boolean>) -> Unit) {
coroutineScope.launch {
try {
val status = mlkitModel.checkStatus()
callback(Result.success(status == FeatureStatus.AVAILABLE))
} catch (e: Exception) {
callback(Result.success(false))
}
}
}

override fun generateContent(prompt: String, callback: (Result<String>) -> Unit) {
coroutineScope.launch {
try {
val request = GenerateContentRequest.builder(TextPart(prompt)).build()
val response = mlkitModel.generateContent(request)
val text = response.candidates.firstOrNull()?.text ?: ""
callback(Result.success(text))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}

override fun warmup(callback: (Result<Unit>) -> Unit) {
coroutineScope.launch {
try {
mlkitModel.warmup()
callback(Result.success(Unit))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}

override fun startStreaming(prompt: String, callback: (Result<Unit>) -> Unit) {
coroutineScope.launch {
try {
val request = GenerateContentRequest.builder(TextPart(prompt)).build()
mlkitModel.generateContentStream(request)
.catch { e ->
callback(Result.failure(e))
}
.collect { chunk ->
val text = chunk.candidates.firstOrNull()?.text ?: ""
if (text.isNotEmpty()) {
LocalAIStreamHandler.shared.sendEvent(text)
}
}
LocalAIStreamHandler.shared.closeStream()
callback(Result.success(Unit))
} catch (e: Exception) {
callback(Result.failure(e))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
plugins {
id("com.android.application")
// START: FlutterFire Configuration
id("com.google.gms.google-services")
// END: FlutterFire Configuration
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
Expand All @@ -22,9 +25,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.example.firebase_ai_example"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
minSdk = 26
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pluginManagement {
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.9.1" apply false
// START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
}

Expand Down
23 changes: 18 additions & 5 deletions packages/firebase_ai/firebase_ai/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,28 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';

// Import after file is generated through flutterfire_cli.
// import 'package:firebase_ai_example/firebase_options.dart';
import 'package:firebase_ai_example/firebase_options.dart';

import 'pages/bidi_page.dart';
import 'pages/chat_page.dart';
import 'pages/function_calling_page.dart';
import 'pages/grounding_page.dart';
import 'pages/hybrid_page.dart';
import 'pages/image_generation_page.dart';
import 'pages/image_prompt_page.dart';
import 'pages/json_schema_page.dart';
import 'pages/multimodal_page.dart';
import 'pages/schema_page.dart';
import 'pages/server_template_page.dart';
import 'pages/grounding_page.dart';
import 'pages/token_count_page.dart';

void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Enable this line instead once have the firebase_options.dart generated and
// imported through flutterfire_cli.
// await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await Firebase.initializeApp();
await FirebaseAuth.instance.signInAnonymously();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// await Firebase.initializeApp();
// await FirebaseAuth.instance.signInAnonymously();
runApp(const GenerativeAISample());
}

Expand Down Expand Up @@ -180,6 +181,11 @@ class _HomeScreenState extends State<HomeScreen> {
title: 'Grounding',
useVertexBackend: useVertexBackend,
);
case 11:
return HybridPage(
title: 'Hybrid Mode',
model: currentModel,
);

default:
// Fallback to the first page in case of an unexpected index
Expand Down Expand Up @@ -314,6 +320,13 @@ class _HomeScreenState extends State<HomeScreen> {
label: 'Grounding',
tooltip: 'Search & Maps Grounding',
),
BottomNavigationBarItem(
icon: Icon(
Icons.auto_awesome,
),
label: 'Hybrid',
tooltip: 'Hybrid Mode',
),
],
currentIndex: _selectedIndex,
onTap: _onItemTapped,
Expand Down
Loading
Loading