Skip to content
Draft
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
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