diff --git a/packages/firebase_ai/firebase_ai/android/build.gradle b/packages/firebase_ai/firebase_ai/android/build.gradle new file mode 100644 index 000000000000..13affc9f6740 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/android/build.gradle @@ -0,0 +1,49 @@ +group 'io.flutter.plugins.firebase.ai' +version '1.0-SNAPSHOT' + +apply plugin: 'com.android.library' +apply from: file("local-config.gradle") + +// AGP 9+ has built-in Kotlin support; older versions need the plugin explicitly. +def agpMajor = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0] as int +if (agpMajor < 9) { + apply plugin: 'kotlin-android' +} + +buildscript { + repositories { + google() + mavenCentral() + } +} + +android { + if (project.android.hasProperty("namespace")) { + namespace 'io.flutter.plugins.firebase.ai' + } + + compileSdkVersion project.ext.compileSdk + + defaultConfig { + minSdkVersion project.ext.minSdk + } + + compileOptions { + sourceCompatibility project.ext.javaVersion + targetCompatibility project.ext.javaVersion + } + + if (agpMajor < 9) { + kotlinOptions { + jvmTarget = project.ext.javaVersion + } + } + + sourceSets { + main.java.srcDirs += "src/main/kotlin" + } + + lintOptions { + disable 'InvalidPackage' + } +} diff --git a/packages/firebase_ai/firebase_ai/android/local-config.gradle b/packages/firebase_ai/firebase_ai/android/local-config.gradle new file mode 100644 index 000000000000..2adcdf5c1729 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/android/local-config.gradle @@ -0,0 +1,7 @@ +ext { + compileSdk=34 + minSdk=23 + targetSdk=34 + javaVersion = JavaVersion.toVersion(17) + androidGradlePluginVersion = '8.3.0' +} diff --git a/packages/firebase_ai/firebase_ai/android/src/main/AndroidManifest.xml b/packages/firebase_ai/firebase_ai/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..db54cec9bea5 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/android/src/main/AndroidManifest.xml @@ -0,0 +1,3 @@ + + diff --git a/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt new file mode 100644 index 000000000000..3377f693d3e5 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/android/src/main/kotlin/io/flutter/plugins/firebase/ai/FirebaseAIPlugin.kt @@ -0,0 +1,101 @@ +// 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 android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException + +class FirebaseAIPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + private lateinit var channel: MethodChannel + private lateinit var context: Context + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + channel = MethodChannel(binding.binaryMessenger, "plugins.flutter.io/firebase_ai") + channel.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + "getPlatformHeaders" -> { + val headers = mapOf( + "X-Android-Package" to context.packageName, + "X-Android-Cert" to (getSigningCertFingerprint() ?: "") + ) + result.success(headers) + } + else -> result.notImplemented() + } + } + + @OptIn(ExperimentalStdlibApi::class) + private fun getSigningCertFingerprint(): String? { + val packageName = context.packageName + val signature = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val packageInfo = try { + context.packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNING_CERTIFICATES + ) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "PackageManager couldn't find the package \"$packageName\"", e) + return null + } + val signingInfo = packageInfo?.signingInfo ?: return null + if (signingInfo.hasMultipleSigners()) { + signingInfo.apkContentsSigners.firstOrNull() + } else { + signingInfo.signingCertificateHistory.lastOrNull() + } + } else { + @Suppress("DEPRECATION") + val packageInfo = try { + context.packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNATURES + ) + } catch (e: PackageManager.NameNotFoundException) { + Log.e(TAG, "PackageManager couldn't find the package \"$packageName\"", e) + return null + } + @Suppress("DEPRECATION") + packageInfo?.signatures?.firstOrNull() + } ?: return null + + return try { + val messageDigest = MessageDigest.getInstance("SHA-1") + val digest = messageDigest.digest(signature.toByteArray()) + digest.toHexString(HexFormat.UpperCase) + } catch (e: NoSuchAlgorithmException) { + Log.w(TAG, "No support for SHA-1 algorithm found.", e) + null + } + } + + companion object { + private const val TAG = "FirebaseAIPlugin" + } +} diff --git a/packages/firebase_ai/firebase_ai/ios/firebase_ai.podspec b/packages/firebase_ai/firebase_ai/ios/firebase_ai.podspec new file mode 100644 index 000000000000..25d4ecfe3e5d --- /dev/null +++ b/packages/firebase_ai/firebase_ai/ios/firebase_ai.podspec @@ -0,0 +1,28 @@ +require 'yaml' + +pubspec = YAML.load_file(File.join('..', 'pubspec.yaml')) +library_version = pubspec['version'].gsub('+', '-') + +Pod::Spec.new do |s| + s.name = pubspec['name'] + s.version = library_version + s.summary = pubspec['description'] + s.description = pubspec['description'] + s.homepage = pubspec['homepage'] + s.license = { :file => '../LICENSE' } + s.authors = 'The Chromium Authors' + s.source = { :path => '.' } + + s.source_files = 'firebase_ai/Sources/firebase_ai/**/*.swift' + + s.ios.deployment_target = '15.0' + s.dependency 'Flutter' + + s.swift_version = '5.0' + + s.static_framework = true + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => "LIBRARY_VERSION=\\\"#{library_version}\\\" LIBRARY_NAME=\\\"flutter-fire-ai\\\"", + 'DEFINES_MODULE' => 'YES' + } +end diff --git a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift new file mode 100644 index 000000000000..a76212182bda --- /dev/null +++ b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift @@ -0,0 +1,49 @@ +// 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. + +#if canImport(FlutterMacOS) + import FlutterMacOS +#else + import Flutter +#endif + +public class FirebaseAIPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + #if canImport(FlutterMacOS) + let messenger = registrar.messenger + #else + let messenger = registrar.messenger() + #endif + + let channel = FlutterMethodChannel( + name: "plugins.flutter.io/firebase_ai", + binaryMessenger: messenger + ) + let instance = FirebaseAIPlugin() + registrar.addMethodCallDelegate(instance, channel: channel) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "getPlatformHeaders": + var headers: [String: String] = [:] + if let bundleId = Bundle.main.bundleIdentifier { + headers["x-ios-bundle-identifier"] = bundleId + } + result(headers) + default: + result(FlutterMethodNotImplemented) + } + } +} diff --git a/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/Resources/.gitkeep b/packages/firebase_ai/firebase_ai/ios/firebase_ai/Sources/firebase_ai/Resources/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/firebase_ai/firebase_ai/lib/src/base_model.dart b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart index 01ac7eb834b3..cf1f98db8b1a 100644 --- a/packages/firebase_ai/firebase_ai/lib/src/base_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart @@ -36,6 +36,7 @@ import 'imagen/imagen_edit.dart'; import 'imagen/imagen_reference.dart'; import 'live_api.dart'; import 'live_session.dart'; +import 'platform_header_helper.dart'; import 'tool.dart'; part 'generative_model.dart'; @@ -300,6 +301,10 @@ abstract class BaseModel { if (app != null && app.isAutomaticDataCollectionEnabled) { headers['X-Firebase-AppId'] = app.options.appId; } + // Add platform-specific headers for API key restrictions. + // Android: X-Android-Package + X-Android-Cert + // iOS/macOS: x-ios-bundle-identifier + headers.addAll(await getPlatformSecurityHeaders()); return headers; }; } diff --git a/packages/firebase_ai/firebase_ai/lib/src/platform_header_helper.dart b/packages/firebase_ai/firebase_ai/lib/src/platform_header_helper.dart new file mode 100644 index 000000000000..9f0d115756de --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/src/platform_header_helper.dart @@ -0,0 +1,50 @@ +// 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. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Method channel for the native platform helper plugin. +@visibleForTesting +const platformHeaderChannel = MethodChannel('plugins.flutter.io/firebase_ai'); + +Map? _cachedHeaders; + +/// Clears the cached platform headers. Only for use in tests. +@visibleForTesting +void clearPlatformSecurityHeadersCache() { + _cachedHeaders = null; +} + +/// Returns platform-specific security headers for API key restrictions. +/// +/// Each platform's native plugin returns the appropriate headers: +/// - **Android**: `X-Android-Package` and `X-Android-Cert` +/// - **iOS/macOS**: `x-ios-bundle-identifier` +/// - **Web/other**: empty map (no plugin registered) +/// +/// Results are cached since platform identity does not change at runtime. +Future> getPlatformSecurityHeaders() async { + if (kIsWeb) return const {}; + if (_cachedHeaders != null) return _cachedHeaders!; + + try { + final result = await platformHeaderChannel + .invokeMapMethod('getPlatformHeaders'); + _cachedHeaders = result ?? const {}; + } catch (_) { + _cachedHeaders = const {}; + } + return _cachedHeaders!; +} diff --git a/packages/firebase_ai/firebase_ai/macos/firebase_ai.podspec b/packages/firebase_ai/firebase_ai/macos/firebase_ai.podspec new file mode 100644 index 000000000000..ebe84b6322df --- /dev/null +++ b/packages/firebase_ai/firebase_ai/macos/firebase_ai.podspec @@ -0,0 +1,28 @@ +require 'yaml' + +pubspec = YAML.load_file(File.join('..', 'pubspec.yaml')) +library_version = pubspec['version'].gsub('+', '-') + +Pod::Spec.new do |s| + s.name = pubspec['name'] + s.version = library_version + s.summary = pubspec['description'] + s.description = pubspec['description'] + s.homepage = pubspec['homepage'] + s.license = { :file => '../LICENSE' } + s.authors = 'The Chromium Authors' + s.source = { :path => '.' } + + s.source_files = 'firebase_ai/Sources/firebase_ai/**/*.swift' + + s.platform = :osx, '10.15' + s.swift_version = '5.0' + + s.dependency 'FlutterMacOS' + + s.static_framework = true + s.pod_target_xcconfig = { + 'GCC_PREPROCESSOR_DEFINITIONS' => "LIBRARY_VERSION=\\\"#{library_version}\\\" LIBRARY_NAME=\\\"flutter-fire-ai\\\"", + 'DEFINES_MODULE' => 'YES' + } +end diff --git a/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift new file mode 120000 index 000000000000..d0761e47c393 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift @@ -0,0 +1 @@ +../../../../ios/firebase_ai/Sources/firebase_ai/FirebaseAIPlugin.swift \ No newline at end of file diff --git a/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/Resources/.gitkeep b/packages/firebase_ai/firebase_ai/macos/firebase_ai/Sources/firebase_ai/Resources/.gitkeep new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/firebase_ai/firebase_ai/pubspec.yaml b/packages/firebase_ai/firebase_ai/pubspec.yaml index e545f84b43b2..0304dac61456 100644 --- a/packages/firebase_ai/firebase_ai/pubspec.yaml +++ b/packages/firebase_ai/firebase_ai/pubspec.yaml @@ -37,3 +37,14 @@ dev_dependencies: matcher: ^0.12.16 mockito: ^5.0.0 plugin_platform_interface: ^2.1.3 + +flutter: + plugin: + platforms: + android: + package: io.flutter.plugins.firebase.ai + pluginClass: FirebaseAIPlugin + ios: + pluginClass: FirebaseAIPlugin + macos: + pluginClass: FirebaseAIPlugin diff --git a/packages/firebase_ai/firebase_ai/test/base_model_test.dart b/packages/firebase_ai/firebase_ai/test/base_model_test.dart index 2cefadf4f39a..089ef6fe2382 100644 --- a/packages/firebase_ai/firebase_ai/test/base_model_test.dart +++ b/packages/firebase_ai/firebase_ai/test/base_model_test.dart @@ -14,9 +14,12 @@ import 'package:firebase_ai/src/base_model.dart'; import 'package:firebase_ai/src/client.dart'; +import 'package:firebase_ai/src/platform_header_helper.dart'; import 'package:firebase_app_check/firebase_app_check.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; @@ -74,7 +77,11 @@ class MockApiClient extends Mock implements ApiClient { } void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('BaseModel', () { + setUp(clearPlatformSecurityHeadersCache); + test('firebaseTokens returns a function that generates headers', () async { final tokenFunction = BaseModel.firebaseTokens(null, null, null, false); final headers = await tokenFunction(); @@ -159,5 +166,58 @@ void main() { expect(headers['x-goog-api-client'], contains('fire')); expect(headers.length, 2); }); + + test('firebaseTokens includes Android platform headers when available', + () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + addTearDown(() { + debugDefaultTargetPlatformOverride = null; + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, + (MethodCall methodCall) async { + return { + 'X-Android-Package': 'com.example.test', + 'X-Android-Cert': 'AABBCCDD', + }; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, null); + }); + + final tokenFunction = BaseModel.firebaseTokens(null, null, null, false); + final headers = await tokenFunction(); + expect(headers['X-Android-Package'], 'com.example.test'); + expect(headers['X-Android-Cert'], 'AABBCCDD'); + expect(headers['x-goog-api-client'], contains('gl-dart')); + expect(headers.length, 3); + }); + + test('firebaseTokens includes iOS bundle identifier when available', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, + (MethodCall methodCall) async { + return { + 'x-ios-bundle-identifier': 'com.example.iosapp', + }; + }); + addTearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, null); + }); + + final mockApp = MockFirebaseApp(); + + final tokenFunction = + BaseModel.firebaseTokens(null, null, mockApp, false); + final headers = await tokenFunction(); + expect(headers['x-ios-bundle-identifier'], 'com.example.iosapp'); + expect(headers['X-Firebase-AppId'], 'test-app-id'); + expect(headers['x-goog-api-client'], contains('gl-dart')); + expect(headers.length, 3); + }); }); } diff --git a/packages/firebase_ai/firebase_ai/test/platform_header_helper_test.dart b/packages/firebase_ai/firebase_ai/test/platform_header_helper_test.dart new file mode 100644 index 000000000000..442b56c95577 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/platform_header_helper_test.dart @@ -0,0 +1,111 @@ +// 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. + +import 'package:firebase_ai/src/platform_header_helper.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(clearPlatformSecurityHeadersCache); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, null); + }); + + group('getPlatformSecurityHeaders', () { + test('returns headers from native plugin', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, + (MethodCall methodCall) async { + if (methodCall.method == 'getPlatformHeaders') { + return { + 'X-Android-Package': 'com.example.test', + 'X-Android-Cert': 'AABBCCDD', + }; + } + return null; + }); + + final headers = await getPlatformSecurityHeaders(); + + expect(headers['X-Android-Package'], 'com.example.test'); + expect(headers['X-Android-Cert'], 'AABBCCDD'); + expect(headers.length, 2); + }); + + test('returns iOS bundle identifier from native plugin', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, + (MethodCall methodCall) async { + if (methodCall.method == 'getPlatformHeaders') { + return { + 'x-ios-bundle-identifier': 'com.example.iosapp', + }; + } + return null; + }); + + final headers = await getPlatformSecurityHeaders(); + + expect(headers['x-ios-bundle-identifier'], 'com.example.iosapp'); + expect(headers.length, 1); + }); + + test('caches result across calls', () async { + var callCount = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, + (MethodCall methodCall) async { + callCount++; + return { + 'X-Android-Package': 'com.example.test', + 'X-Android-Cert': 'AABBCCDD', + }; + }); + + await getPlatformSecurityHeaders(); + await getPlatformSecurityHeaders(); + await getPlatformSecurityHeaders(); + + expect(callCount, 1); + }); + + test('returns empty map when native plugin is not available', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, + (MethodCall methodCall) async { + throw MissingPluginException(); + }); + + final headers = await getPlatformSecurityHeaders(); + + expect(headers, isEmpty); + }); + + test('returns empty map when native plugin returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(platformHeaderChannel, + (MethodCall methodCall) async { + return null; + }); + + final headers = await getPlatformSecurityHeaders(); + + expect(headers, isEmpty); + }); + }); +} diff --git a/tests/integration_test/e2e_test.dart b/tests/integration_test/e2e_test.dart index 7f5d657b1b56..75818dc8f3a0 100644 --- a/tests/integration_test/e2e_test.dart +++ b/tests/integration_test/e2e_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; import 'cloud_functions/cloud_functions_e2e_test.dart' as cloud_functions; +import 'firebase_ai/firebase_ai_e2e_test.dart' as firebase_ai; import 'firebase_analytics/firebase_analytics_e2e_test.dart' as firebase_analytics; import 'firebase_app_check/firebase_app_check_e2e_test.dart' @@ -51,6 +52,7 @@ void main() { } if (kIsWeb) { firebase_core.main(); + firebase_ai.main(); firebase_auth.main(); firebase_database.main(); firebase_crashlytics.main(); @@ -87,6 +89,7 @@ void main() { void runAllTests() { firebase_core.main(); + firebase_ai.main(); firebase_auth.main(); firebase_database.main(); firebase_crashlytics.main(); diff --git a/tests/integration_test/firebase_ai/firebase_ai_e2e_test.dart b/tests/integration_test/firebase_ai/firebase_ai_e2e_test.dart new file mode 100644 index 000000000000..75c7d67f7720 --- /dev/null +++ b/tests/integration_test/firebase_ai/firebase_ai_e2e_test.dart @@ -0,0 +1,94 @@ +// Copyright 2026, the Chromium project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +const _channel = MethodChannel('plugins.flutter.io/firebase_ai'); + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('firebase_ai', () { + group('platform security headers', () { + testWidgets( + 'returns non-empty headers on mobile platforms', + skip: kIsWeb, + (WidgetTester tester) async { + final headers = await _channel.invokeMapMethod( + 'getPlatformHeaders', + ); + + expect( + headers, + isNotNull, + reason: 'Native plugin should return platform headers', + ); + expect( + headers, + isNotEmpty, + reason: 'Native plugin should return non-empty platform headers', + ); + }, + ); + + testWidgets( + 'returns correct Android headers', + skip: kIsWeb || defaultTargetPlatform != TargetPlatform.android, + (WidgetTester tester) async { + final headers = await _channel.invokeMapMethod( + 'getPlatformHeaders', + ); + + expect(headers, isNotNull); + expect(headers, contains('X-Android-Package')); + expect( + headers!['X-Android-Package'], + isNotEmpty, + reason: 'Package name should not be empty', + ); + // Cert may be empty in some emulator environments, but key must exist. + expect(headers, contains('X-Android-Cert')); + }, + ); + + testWidgets( + 'returns correct iOS/macOS headers', + skip: kIsWeb || + (defaultTargetPlatform != TargetPlatform.iOS && + defaultTargetPlatform != TargetPlatform.macOS), + (WidgetTester tester) async { + final headers = await _channel.invokeMapMethod( + 'getPlatformHeaders', + ); + + expect(headers, isNotNull); + expect(headers, contains('x-ios-bundle-identifier')); + expect( + headers!['x-ios-bundle-identifier'], + isNotEmpty, + reason: 'Bundle identifier should not be empty', + ); + }, + ); + + testWidgets( + 'returns empty headers on web', + skip: !kIsWeb, + (WidgetTester tester) async { + // On web, no native plugin is registered, so the channel call + // should throw a MissingPluginException. + expect( + () => _channel.invokeMapMethod( + 'getPlatformHeaders', + ), + throwsA(isA()), + ); + }, + ); + }); + }); +} diff --git a/tests/pubspec.yaml b/tests/pubspec.yaml index 0fb1e979b9cd..677d7fecbbaf 100644 --- a/tests/pubspec.yaml +++ b/tests/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: cloud_functions_platform_interface: ^5.8.10 cloud_functions_web: ^5.1.3 collection: ^1.15.0 + firebase_ai: ^3.9.0 firebase_analytics: ^12.1.3 firebase_analytics_platform_interface: ^5.0.7 firebase_analytics_web: ^0.6.1+3