diff --git a/packages/firebase_ai/all_lint_rules.yaml b/packages/firebase_ai/all_lint_rules.yaml new file mode 100644 index 000000000000..dc4ec3a951c3 --- /dev/null +++ b/packages/firebase_ai/all_lint_rules.yaml @@ -0,0 +1,183 @@ +# Copyright 2021 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# in the LICENSE file. + +linter: + rules: + - always_declare_return_types + - always_put_control_body_on_new_line + - always_put_required_named_parameters_first + - always_require_non_null_named_parameters + - always_specify_types + - always_use_package_imports + - annotate_overrides + - avoid_annotating_with_dynamic + - avoid_bool_literals_in_conditional_expressions + - avoid_catches_without_on_clauses + - avoid_catching_errors + - avoid_classes_with_only_static_members + - avoid_double_and_int_checks + - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes + - avoid_escaping_inner_quotes + - avoid_field_initializers_in_const_classes + - avoid_function_literals_in_foreach_calls + - avoid_implementing_value_types + - avoid_init_to_null + - avoid_js_rounded_ints + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters + - avoid_print + - avoid_private_typedef_functions + - avoid_redundant_argument_values + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null + - avoid_returning_null_for_future + - avoid_returning_null_for_void + - avoid_returning_this + - avoid_setters_without_getters + - avoid_shadowing_type_parameters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_type_to_string + - avoid_types_as_parameter_names + - avoid_types_on_closure_parameters + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + - avoid_web_libraries_in_flutter + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + - cascade_invocations + - cast_nullable_to_non_nullable + - close_sinks + - comment_references + - constant_identifier_names + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - diagnostic_describe_all_properties + - directives_ordering + - do_not_use_environment + - empty_catches + - empty_constructor_bodies + - empty_statements + - exhaustive_cases + - file_names + - flutter_style_todos + - hash_and_equals + - implementation_imports + - invariant_booleans + - iterable_contains_unrelated_type + - join_return_with_assignment + - leading_newlines_in_multiline_strings + - library_names + - library_prefixes + - lines_longer_than_80_chars + - list_remove_unrelated_type + - literal_only_boolean_expressions + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_default_cases + - no_duplicate_case_values + - no_logic_in_create_state + - no_runtimeType_toString + - non_constant_identifier_names + - null_check_on_nullable_type_parameter + - null_closures + - omit_local_variable_types + - one_member_abstracts + - only_throw_errors + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + - parameter_assignments + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + - prefer_asserts_with_message + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + - prefer_constructors_over_static_methods + - prefer_contains + - prefer_double_quotes + - prefer_equal_for_default_values + - prefer_expression_function_bodies + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + - prefer_int_literals + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + - prefer_mixin + - prefer_null_aware_operators + - prefer_relative_imports + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + - public_member_api_docs + - recursive_getters + - sized_box_for_whitespace + - slash_for_doc_comments + - sort_child_properties_last + - sort_constructors_first + - sort_pub_dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + - tighten_type_of_initializing_formals + - type_annotate_public_apis + - type_init_formals + - unawaited_futures + - unnecessary_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_const + - unnecessary_final + - unnecessary_getters_setters + - unnecessary_lambdas + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + - unnecessary_raw_strings + - unnecessary_statements + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unrelated_type_equality_checks + - unsafe_html + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_is_even_rather_than_modulo + - use_key_in_widget_constructors + - use_late_for_private_fields_and_variables + - use_raw_strings + - use_rethrow_when_possible + - use_setters_to_change_properties + - use_string_buffers + - use_to_and_as_if_applicable + - valid_regexps + - void_checks diff --git a/packages/firebase_ai/analysis_options.yaml b/packages/firebase_ai/analysis_options.yaml new file mode 100644 index 000000000000..f41d32929033 --- /dev/null +++ b/packages/firebase_ai/analysis_options.yaml @@ -0,0 +1,88 @@ +# Copyright 2021 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# in the LICENSE file. + +include: all_lint_rules.yaml +analyzer: + # TODO(rrousselGit): disable implicit-cast/implicit-dynamic + errors: + # Otherwise cause the import of all_lint_rules to warn because of some rules conflicts. + # We explicitly enabled even conflicting rules and are fixing the conflict + # in this file + included_file_warning: ignore + +linter: + rules: + ## Disabled rules because the repository doesn't respect them (yet) + + always_put_control_body_on_new_line: false + comment_references: false + prefer_constructors_over_static_methods: false + prefer_final_fields: false + prefer_final_locals: false + omit_local_variable_types: false + avoid_equals_and_hash_code_on_mutable_classes: false + + ############# + + # Personal preference. I don't find it more readable + cascade_invocations: false + + # Conflicts with `prefer_single_quotes` + # Single quotes are easier to type and don't compromise on readability. + prefer_double_quotes: false + + # Conflicts with `omit_local_variable_types` and other rules. + # As per Dart guidelines, we want to avoid unnecessary types to make the code + # more readable. + # See https://dart.dev/guides/language/effective-dart/design#avoid-type-annotating-initialized-local-variables + always_specify_types: false + + # Incompatible with `prefer_final_locals` + # Having immutable local variables makes larger functions more predictible + # so we will use `prefer_final_locals` instead. + unnecessary_final: false + + # Not quite suitable for Flutter, which may have a `build` method with a single + # return, but that return is still complex enough that a "body" is worth it. + prefer_expression_function_bodies: false + + # Conflicts with the convention used by flutter, which puts `Key key` + # and `@required Widget child` last. + always_put_required_named_parameters_first: false + + # This project doesn't use Flutter-style todos + flutter_style_todos: false + + # There are situations where we voluntarily want to catch everything, + # especially as a library. + avoid_catches_without_on_clauses: false + + # Boring as it sometimes force a line of 81 characters to be split in two. + # As long as we try to respect that 80 characters limit, going slightly + # above is fine. + lines_longer_than_80_chars: false + + # Conflicts with disabling `implicit-dynamic` + avoid_annotating_with_dynamic: false + + # conflicts with `prefer_relative_imports` + always_use_package_imports: false + + # Disabled for now until we have NNBD as it otherwise conflicts with `missing_return` + no_default_cases: false + + # False positive, null checks don't need a message + prefer_asserts_with_message: false + + # Cumbersome with `context.select` + avoid_types_on_closure_parameters: false + + # Too many false positive (builders) + diagnostic_describe_all_properties: false + + # false positives (setter-like functions) + avoid_positional_boolean_parameters: false + + # Does not apply to providers + prefer_const_constructors_in_immutables: false diff --git a/packages/firebase_ai/firebase_ai/CHANGELOG.md b/packages/firebase_ai/firebase_ai/CHANGELOG.md new file mode 100644 index 000000000000..3a390d673f0e --- /dev/null +++ b/packages/firebase_ai/firebase_ai/CHANGELOG.md @@ -0,0 +1,3 @@ +## 2.0.0 + +- Initial release following package rename from `firebase_vertexai`. diff --git a/packages/firebase_ai/firebase_ai/LICENSE b/packages/firebase_ai/firebase_ai/LICENSE new file mode 100644 index 000000000000..e58143fccfb6 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 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. diff --git a/packages/firebase_ai/firebase_ai/README.md b/packages/firebase_ai/firebase_ai/README.md new file mode 100644 index 000000000000..4eb87f0ec79d --- /dev/null +++ b/packages/firebase_ai/firebase_ai/README.md @@ -0,0 +1,24 @@ +# Firebase AI Flutter +[![pub package](https://img.shields.io/pub/v/firebase_ai.svg)](https://pub.dev/packages/firebase_ai) + +A Flutter plugin to use the [Firebase AI](https://firebase.google.com/docs/vertex-ai/). + +To learn more about Firebase AI, please visit the [website](https://cloud.google.com/vertex-ai) + +## Getting Started + +To get started with Firebase AI Flutter, please [see the documentation](https://firebase.google.com/docs/vertex-ai/get-started?platform=flutter). + +## Usage + +To start use this plugin, please visit the [Text only prompt documentation](https://firebase.google.com/docs/vertex-ai/text-gen-from-text?platform=flutter) + +## Issues and feedback + +Please file FlutterFire specific issues, bugs, or feature requests in our [issue tracker](https://github.com/firebase/flutterfire/issues/new). + +Plugin issues that are not specific to FlutterFire can be filed in the [Flutter issue tracker](https://github.com/flutter/flutter/issues/new). + +To contribute a change to this plugin, +please review our [contribution guide](https://github.com/firebase/flutterfire/blob/main/CONTRIBUTING.md) +and open a [pull request](https://github.com/firebase/flutterfire/pulls). diff --git a/packages/firebase_ai/firebase_ai/example/.gitignore b/packages/firebase_ai/firebase_ai/example/.gitignore new file mode 100644 index 000000000000..53bed76d8faa --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/.gitignore @@ -0,0 +1,51 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +#firebase +firebase_options.dart +google-services.json +GoogleService-Info.plist +firebase.json diff --git a/packages/firebase_ai/firebase_ai/example/.metadata b/packages/firebase_ai/firebase_ai/example/.metadata new file mode 100644 index 000000000000..784ce1298249 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "a14f74ff3a1cbd521163c5f03d68113d50af93d3" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3 + base_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3 + - platform: web + create_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3 + base_revision: a14f74ff3a1cbd521163c5f03d68113d50af93d3 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/firebase_ai/firebase_ai/example/README.md b/packages/firebase_ai/firebase_ai/example/README.md new file mode 100644 index 000000000000..88bd8f55db39 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/README.md @@ -0,0 +1,16 @@ +# firebase_ai_example + +Sample app to show how to use Firebase AI SDK. + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/packages/firebase_ai/firebase_ai/example/analysis_options.yaml b/packages/firebase_ai/firebase_ai/example/analysis_options.yaml new file mode 100644 index 000000000000..b6cd704fb940 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/analysis_options.yaml @@ -0,0 +1,10 @@ +# Copyright 2021 The Chromium Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# in the LICENSE file. + +include: ../../../../analysis_options.yaml +linter: + rules: + avoid_print: false + depend_on_referenced_packages: false + library_private_types_in_public_api: false diff --git a/packages/firebase_ai/firebase_ai/example/android/.gitignore b/packages/firebase_ai/firebase_ai/example/android/.gitignore new file mode 100644 index 000000000000..6f568019d3c6 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/packages/firebase_ai/firebase_ai/example/android/app/build.gradle b/packages/firebase_ai/firebase_ai/example/android/app/build.gradle new file mode 100644 index 000000000000..1f03f356449c --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/build.gradle @@ -0,0 +1,59 @@ +plugins { + id "com.android.application" + // START: FlutterFire Configuration + id 'com.google.gms.google-services' + // END: FlutterFire Configuration + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace "io.flutter.plugins.firebase.ai.example" + + compileSdk 35 + + defaultConfig { + applicationId "io.flutter.plugins.firebase.ai.example" + minSdk 23 + targetSdk 33 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } + kotlinOptions { + jvmTarget = '1.8' // Or '11' + } +} + +flutter { + source '../..' +} diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/debug/AndroidManifest.xml b/packages/firebase_ai/firebase_ai/example/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 000000000000..399f6981d5d3 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/AndroidManifest.xml b/packages/firebase_ai/firebase_ai/example/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..3401fcfb42b9 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt b/packages/firebase_ai/firebase_ai/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt new file mode 100644 index 000000000000..70f8f08f2479 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/src/main/kotlin/com/example/example/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.example + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity: FlutterActivity() diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 000000000000..f74085f3f6a2 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/drawable/launch_background.xml b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 000000000000..304732f88420 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000000..db77bb4b7b09 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000000..17987b79bb8a Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000000..09d4391482be Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000000..d5f1c8d34e7a Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000000..4d6372eebdb2 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/values-night/styles.xml b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 000000000000..06952be745f9 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/values/styles.xml b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/values/styles.xml new file mode 100644 index 000000000000..cb1ef88056ed --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/android/app/src/profile/AndroidManifest.xml b/packages/firebase_ai/firebase_ai/example/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 000000000000..399f6981d5d3 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/packages/firebase_ai/firebase_ai/example/android/build.gradle b/packages/firebase_ai/firebase_ai/example/android/build.gradle new file mode 100644 index 000000000000..bc157bd1a12b --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/packages/firebase_ai/firebase_ai/example/android/gradle.properties b/packages/firebase_ai/firebase_ai/example/android/gradle.properties new file mode 100644 index 000000000000..598d13fee446 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true diff --git a/packages/firebase_ai/firebase_ai/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/firebase_ai/firebase_ai/example/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..aa49780cd59e --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip diff --git a/packages/firebase_ai/firebase_ai/example/android/settings.gradle b/packages/firebase_ai/firebase_ai/example/android/settings.gradle new file mode 100644 index 000000000000..40cbd22bb13b --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/android/settings.gradle @@ -0,0 +1,28 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "7.3.0" 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 "1.9.22" apply false +} + +include ":app" diff --git a/packages/firebase_ai/firebase_ai/example/assets/documents/gemini_summary.pdf b/packages/firebase_ai/firebase_ai/example/assets/documents/gemini_summary.pdf new file mode 100644 index 000000000000..08881c7839ec Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/assets/documents/gemini_summary.pdf differ diff --git a/packages/firebase_ai/firebase_ai/example/assets/images/cat.jpg b/packages/firebase_ai/firebase_ai/example/assets/images/cat.jpg new file mode 100644 index 000000000000..8d2069e6c979 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/assets/images/cat.jpg differ diff --git a/packages/firebase_ai/firebase_ai/example/assets/images/scones.jpg b/packages/firebase_ai/firebase_ai/example/assets/images/scones.jpg new file mode 100644 index 000000000000..ce689588e871 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/assets/images/scones.jpg differ diff --git a/packages/firebase_ai/firebase_ai/example/assets/videos/landscape.mp4 b/packages/firebase_ai/firebase_ai/example/assets/videos/landscape.mp4 new file mode 100644 index 000000000000..a7f4298dd7e9 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/assets/videos/landscape.mp4 differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/.gitignore b/packages/firebase_ai/firebase_ai/example/ios/.gitignore new file mode 100644 index 000000000000..7a7f9873ad7d --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/packages/firebase_ai/firebase_ai/example/ios/Flutter/AppFrameworkInfo.plist b/packages/firebase_ai/firebase_ai/example/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000000..7c5696400627 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 12.0 + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Flutter/Debug.xcconfig b/packages/firebase_ai/firebase_ai/example/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000000..ec97fc6f3021 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/firebase_ai/firebase_ai/example/ios/Flutter/Release.xcconfig b/packages/firebase_ai/firebase_ai/example/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000000..c4855bfe2000 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/packages/firebase_ai/firebase_ai/example/ios/Podfile b/packages/firebase_ai/firebase_ai/example/ios/Podfile new file mode 100644 index 000000000000..e51a31d9ca9d --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Podfile @@ -0,0 +1,44 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.pbxproj b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..98cd0c1ded4a --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,756 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3414F5B6C6F086F6373F1948 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 5F1FA05866A2D0FCA3287B20 /* GoogleService-Info.plist */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 901FEC83A38129064032C578 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 94CE5BFCDF90764354BB6740 /* Pods_Runner.framework */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + B7B3CA2D70F15615E1B8E5D8 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 154D9627A1C14A5ACE0B7B0D /* Pods_RunnerTests.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 154D9627A1C14A5ACE0B7B0D /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 232D95ECCEC6F04B9CEC8925 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 560CA017EC76D8AAE2E21549 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 5F1FA05866A2D0FCA3287B20 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8ACDC47C7E9AF1A1B9595598 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 94CE5BFCDF90764354BB6740 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A85D07EF8959748E1D3E564B /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + B0B22A9E291076BD22BA9F10 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + E1D0571EA0792087F8F27457 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 0F5F3CD1ED7DB09B81C92173 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + B7B3CA2D70F15615E1B8E5D8 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 901FEC83A38129064032C578 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 3C3B3E8596675CC144D1BD5B /* Pods */ = { + isa = PBXGroup; + children = ( + E1D0571EA0792087F8F27457 /* Pods-Runner.debug.xcconfig */, + 232D95ECCEC6F04B9CEC8925 /* Pods-Runner.release.xcconfig */, + 560CA017EC76D8AAE2E21549 /* Pods-Runner.profile.xcconfig */, + A85D07EF8959748E1D3E564B /* Pods-RunnerTests.debug.xcconfig */, + 8ACDC47C7E9AF1A1B9595598 /* Pods-RunnerTests.release.xcconfig */, + B0B22A9E291076BD22BA9F10 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 5F1FA05866A2D0FCA3287B20 /* GoogleService-Info.plist */, + 3C3B3E8596675CC144D1BD5B /* Pods */, + A50BECFB61A452F592070BAA /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + A50BECFB61A452F592070BAA /* Frameworks */ = { + isa = PBXGroup; + children = ( + 94CE5BFCDF90764354BB6740 /* Pods_Runner.framework */, + 154D9627A1C14A5ACE0B7B0D /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + F5C7CFE0E232B64D613F0623 /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 0F5F3CD1ED7DB09B81C92173 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + F51794D56D63ACA383D5C2E4 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 34F21DFC67109DEAFD936E80 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 3414F5B6C6F086F6373F1948 /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 34F21DFC67109DEAFD936E80 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + F51794D56D63ACA383D5C2E4 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + F5C7CFE0E232B64D613F0623 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YYX2P3XVJ7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = A85D07EF8959748E1D3E564B /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 8ACDC47C7E9AF1A1B9595598 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B0B22A9E291076BD22BA9F10 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YYX2P3XVJ7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = YYX2P3XVJ7; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.example; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..919434a6254f --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000000..f9b0d7c5ea15 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000000..8178cd1c619c --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..21a3cc14c74e --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000000..f9b0d7c5ea15 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/AppDelegate.swift b/packages/firebase_ai/firebase_ai/example/ios/Runner/AppDelegate.swift new file mode 100644 index 000000000000..b6363034812b --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import UIKit +import Flutter + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..d36b1fab2d9d --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 000000000000..dc9ada4725e9 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 000000000000..7353c41ecf9c Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 000000000000..797d452e4589 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 000000000000..6ed2d933e112 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 000000000000..4cd7b0099ca8 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 000000000000..fe730945a01f Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 000000000000..321773cd857a Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 000000000000..797d452e4589 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 000000000000..502f463a9bc8 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 000000000000..0ec303439225 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 000000000000..0ec303439225 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 000000000000..e9f5fea27c70 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 000000000000..84ac32ae7d98 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 000000000000..8953cba09064 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 000000000000..0467bf12aa4d Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 000000000000..0bedcf2fd467 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 000000000000..9da19eacad3b Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 000000000000..89c2725b70f1 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/firebase_ai/firebase_ai/example/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000000..f2e259c7c939 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Base.lproj/Main.storyboard b/packages/firebase_ai/firebase_ai/example/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000000..f3c28516fb38 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Info.plist b/packages/firebase_ai/firebase_ai/example/ios/Runner/Info.plist new file mode 100644 index 000000000000..a80f00ea0116 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner/Info.plist @@ -0,0 +1,51 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Example + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + example + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + NSMicrophoneUsageDescription + We need access to the microphone to record audio. + + diff --git a/packages/firebase_ai/firebase_ai/example/ios/Runner/Runner-Bridging-Header.h b/packages/firebase_ai/firebase_ai/example/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 000000000000..308a2a560b42 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/packages/firebase_ai/firebase_ai/example/ios/firebase_app_id_file.json b/packages/firebase_ai/firebase_ai/example/ios/firebase_app_id_file.json new file mode 100644 index 000000000000..59a23a1a01cc --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/ios/firebase_app_id_file.json @@ -0,0 +1,7 @@ +{ + "file_generated_by": "FlutterFire CLI", + "purpose": "FirebaseAppID & ProjectID for this Firebase app in this directory", + "GOOGLE_APP_ID": "1:651313571784:ios:2f1472905da3e8e9b1c2fd", + "FIREBASE_PROJECT_ID": "vertex-ai-example-ef5a2", + "GCM_SENDER_ID": "651313571784" +} \ No newline at end of file diff --git a/packages/firebase_ai/firebase_ai/example/lib/main.dart b/packages/firebase_ai/firebase_ai/example/lib/main.dart new file mode 100644 index 000000000000..b9d2beeba9a6 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/main.dart @@ -0,0 +1,320 @@ +// Copyright 2024 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_core/firebase_core.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/material.dart'; + +// Import after file is generated through flutterfire_cli. +// import 'package:firebase_ai_example/firebase_options.dart'; + +import 'pages/chat_page.dart'; +import 'pages/audio_page.dart'; +import 'pages/function_calling_page.dart'; +import 'pages/image_prompt_page.dart'; +import 'pages/token_count_page.dart'; +import 'pages/schema_page.dart'; +import 'pages/imagen_page.dart'; +import 'pages/document.dart'; +import 'pages/video_page.dart'; +import 'pages/bidi_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(); + runApp(const GenerativeAISample()); +} + +class GenerativeAISample extends StatefulWidget { + const GenerativeAISample({super.key}); + + @override + State createState() => _GenerativeAISampleState(); +} + +class _GenerativeAISampleState extends State { + bool _useVertexBackend = false; + late GenerativeModel _currentModel; + late ImagenModel _currentImagenModel; + int _currentBottomNavIndex = 0; + + @override + void initState() { + super.initState(); + + _initializeModel(_useVertexBackend); + } + + void _initializeModel(bool useVertexBackend) { + if (useVertexBackend) { + final vertexInstance = FirebaseAI.vertexAI(auth: FirebaseAuth.instance); + _currentModel = vertexInstance.generativeModel(model: 'gemini-1.5-flash'); + _currentImagenModel = _initializeImagenModel(vertexInstance); + } else { + final googleAI = FirebaseAI.googleAI(auth: FirebaseAuth.instance); + _currentModel = googleAI.generativeModel(model: 'gemini-2.0-flash'); + _currentImagenModel = _initializeImagenModel(googleAI); + } + } + + ImagenModel _initializeImagenModel(FirebaseAI instance) { + var generationConfig = ImagenGenerationConfig( + numberOfImages: 1, + aspectRatio: ImagenAspectRatio.square1x1, + imageFormat: ImagenFormat.jpeg(compressionQuality: 75), + ); + return instance.imagenModel( + model: 'imagen-3.0-generate-002', + generationConfig: generationConfig, + safetySettings: ImagenSafetySettings( + ImagenSafetyFilterLevel.blockLowAndAbove, + ImagenPersonFilterLevel.allowAdult, + ), + ); + } + + void _toggleBackend(bool value) { + setState(() { + _useVertexBackend = value; + }); + _initializeModel(_useVertexBackend); + } + + void _onBottomNavTapped(int index) { + setState(() { + _currentBottomNavIndex = index; + }); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter + ${_useVertexBackend ? 'Vertex AI' : 'Google AI'}', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed( + brightness: Brightness.dark, + seedColor: const Color.fromARGB(255, 171, 222, 244), + ), + useMaterial3: true, + ), + home: HomeScreen( + key: ValueKey( + '${_useVertexBackend}_${_currentModel.hashCode}', + ), + model: _currentModel, + imagenModel: _currentImagenModel, + useVertexBackend: _useVertexBackend, + onBackendChanged: _toggleBackend, + selectedIndex: _currentBottomNavIndex, + onSelectedIndexChanged: _onBottomNavTapped, + ), + ); + } +} + +class HomeScreen extends StatefulWidget { + final GenerativeModel model; + final ImagenModel imagenModel; + final bool useVertexBackend; + final ValueChanged onBackendChanged; + final int selectedIndex; + final ValueChanged onSelectedIndexChanged; + + const HomeScreen({ + super.key, + required this.model, + required this.imagenModel, + required this.useVertexBackend, + required this.onBackendChanged, + required this.selectedIndex, + required this.onSelectedIndexChanged, + }); + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + void _onItemTapped(int index) { + widget.onSelectedIndexChanged(index); + } + +// Method to build the selected page on demand + Widget _buildSelectedPage( + int index, + GenerativeModel currentModel, + ImagenModel currentImagenModel, + bool useVertexBackend, + ) { + switch (index) { + case 0: + return ChatPage(title: 'Chat', model: currentModel); + case 1: + return AudioPage(title: 'Audio', model: currentModel); + case 2: + return TokenCountPage(title: 'Token Count', model: currentModel); + case 3: + // FunctionCallingPage initializes its own model as per original design + return FunctionCallingPage( + title: 'Function Calling', + useVertexBackend: useVertexBackend, + ); + case 4: + return ImagePromptPage(title: 'Image Prompt', model: currentModel); + case 5: + return ImagenPage(title: 'Imagen Model', model: currentImagenModel); + case 6: + return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); + case 7: + return DocumentPage(title: 'Document Prompt', model: currentModel); + case 8: + return VideoPage(title: 'Video Prompt', model: currentModel); + case 9: + return BidiPage(title: 'Bidi Stream', model: currentModel); + default: + // Fallback to the first page in case of an unexpected index + return ChatPage(title: 'Chat', model: currentModel); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text( + 'Flutter + ${widget.useVertexBackend ? 'Vertex AI' : 'Google AI'}', + ), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Google AI', + style: TextStyle( + fontSize: 12, + color: widget.useVertexBackend + ? Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7) + : Theme.of(context).colorScheme.primary, + ), + ), + Switch( + value: widget.useVertexBackend, + onChanged: widget.onBackendChanged, + activeTrackColor: Colors.green.withValues(alpha: 0.5), + inactiveTrackColor: Colors.blueGrey.withValues(alpha: 0.5), + activeColor: Colors.green, + inactiveThumbColor: Colors.blueGrey, + ), + Text( + 'Vertex AI', + style: TextStyle( + fontSize: 12, + color: widget.useVertexBackend + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], + ), + body: Center( + child: _buildSelectedPage( + widget.selectedIndex, + widget.model, + widget.imagenModel, + widget.useVertexBackend, + ), + ), + bottomNavigationBar: BottomNavigationBar( + type: BottomNavigationBarType.fixed, + selectedFontSize: 10, + unselectedFontSize: 9, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.chat), + label: 'Chat', + tooltip: 'Chat', + ), + BottomNavigationBarItem( + icon: Icon(Icons.mic), + label: 'Audio', + tooltip: 'Audio Prompt', + ), + BottomNavigationBarItem( + icon: Icon(Icons.numbers), + label: 'Tokens', + tooltip: 'Token Count', + ), + BottomNavigationBarItem( + icon: Icon(Icons.functions), + label: 'Functions', + tooltip: 'Function Calling', + ), + BottomNavigationBarItem( + icon: Icon(Icons.image), + label: 'Image', + tooltip: 'Image Prompt', + ), + BottomNavigationBarItem( + icon: Icon(Icons.image_search), + label: 'Imagen', + tooltip: 'Imagen Model', + ), + BottomNavigationBarItem( + icon: Icon(Icons.schema), + label: 'Schema', + tooltip: 'Schema Prompt', + ), + BottomNavigationBarItem( + icon: Icon(Icons.edit_document), + label: 'Document', + tooltip: 'Document Prompt', + ), + BottomNavigationBarItem( + icon: Icon(Icons.video_collection), + label: 'Video', + tooltip: 'Video Prompt', + ), + BottomNavigationBarItem( + icon: Icon(Icons.stream), + label: 'Bidi', + tooltip: 'Bidi Stream', + ), + ], + currentIndex: widget.selectedIndex, + onTap: _onItemTapped, + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/audio_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/audio_page.dart new file mode 100644 index 000000000000..8708bcb01c15 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/audio_page.dart @@ -0,0 +1,186 @@ +// Copyright 2025 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 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../widgets/message_widget.dart'; +import 'package:record/record.dart'; +import 'package:path_provider/path_provider.dart'; + +final record = AudioRecorder(); + +class AudioPage extends StatefulWidget { + const AudioPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _AudioPageState(); +} + +class _AudioPageState extends State { + ChatSession? chat; + final ScrollController _scrollController = ScrollController(); + final List _messages = []; + bool _recording = false; + + @override + void initState() { + super.initState(); + chat = widget.model.startChat(); + } + + Future recordAudio() async { + if (!await record.hasPermission()) { + print('Audio recording permission denied'); + return; + } + + final dir = Directory( + '${(await getApplicationDocumentsDirectory()).path}/libs/recordings', + ); + + // ignore: avoid_slow_async_io + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + String filePath = + '${dir.path}/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; + + await record.start( + const RecordConfig( + encoder: AudioEncoder.wav, + ), + path: filePath, + ); + } + + Future stopRecord() async { + var path = await record.stop(); + + if (path == null) { + print('Failed to stop recording'); + return; + } + + debugPrint('Recording saved to: $path'); + + try { + File file = File(path); + final audio = await file.readAsBytes(); + debugPrint('Audio file size: ${audio.length} bytes'); + + final audioPart = InlineDataPart('audio/wav', audio); + + await _submitAudioToModel(audioPart); + + await file.delete(); + debugPrint('Recording deleted successfully.'); + } catch (e) { + debugPrint('Error processing recording: $e'); + } + } + + Future _submitAudioToModel(audioPart) async { + try { + String textPrompt = 'What is in the audio recording?'; + final prompt = TextPart('What is in the audio recording?'); + + setState(() { + _messages.add(MessageData(text: textPrompt, fromUser: true)); + }); + + final response = await widget.model.generateContent([ + Content.multi([prompt, audioPart]), + ]); + + setState(() { + _messages.add(MessageData(text: response.text, fromUser: false)); + }); + + debugPrint(response.text); + } catch (e) { + debugPrint('Error sending audio to model: $e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + image: _messages[idx].image, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + IconButton( + onPressed: () async { + setState(() { + _recording = !_recording; + }); + if (_recording) { + await recordAudio(); + } else { + await stopRecord(); + } + }, + icon: Icon( + Icons.mic, + color: _recording + ? Colors.blueGrey + : Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox.square( + dimension: 15, + ), + const Text( + 'Tap the mic to record, tap again to submit', + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/bidi_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/bidi_page.dart new file mode 100644 index 000000000000..acd936a7e827 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/bidi_page.dart @@ -0,0 +1,448 @@ +// Copyright 2025 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 'dart:typed_data'; +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../widgets/message_widget.dart'; +import '../utils/audio_player.dart'; +import '../utils/audio_recorder.dart'; + +class BidiPage extends StatefulWidget { + const BidiPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _BidiPageState(); +} + +class LightControl { + final int? brightness; + final String? colorTemperature; + + LightControl({this.brightness, this.colorTemperature}); +} + +class _BidiPageState extends State { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _messages = []; + bool _loading = false; + bool _sessionOpening = false; + bool _recording = false; + late LiveGenerativeModel _liveModel; + late LiveSession _session; + final _audioManager = AudioStreamManager(); + final _audioRecorder = InMemoryAudioRecorder(); + var _chunkBuilder = BytesBuilder(); + var _audioIndex = 0; + StreamController _stopController = StreamController(); + + @override + void initState() { + super.initState(); + + final config = LiveGenerationConfig( + speechConfig: SpeechConfig(voiceName: 'Fenrir'), + responseModalities: [ + ResponseModalities.audio, + ], + ); + + _liveModel = FirebaseAI.vertexAI().liveGenerativeModel( + model: 'gemini-2.0-flash-exp', + liveGenerationConfig: config, + tools: [ + Tool.functionDeclarations([lightControlTool]), + ], + ); + } + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration( + milliseconds: 750, + ), + curve: Curves.easeOutCirc, + ), + ); + } + + @override + void dispose() { + if (_sessionOpening) { + _audioManager.stopAudioPlayer(); + _audioManager.disposeAudioPlayer(); + + _audioRecorder.stopRecording(); + + _stopController.close(); + + _sessionOpening = false; + _session.close(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + image: _messages[idx].image, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + controller: _textController, + onSubmitted: _sendTextPrompt, + ), + ), + const SizedBox.square( + dimension: 15, + ), + IconButton( + tooltip: 'Start Streaming', + onPressed: !_loading + ? () async { + await _setupSession(); + } + : null, + icon: Icon( + Icons.network_wifi, + color: _sessionOpening + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.primary, + ), + ), + IconButton( + tooltip: 'Send Stream Message', + onPressed: !_loading + ? () async { + if (_recording) { + await _stopRecording(); + } else { + await _startRecording(); + } + } + : null, + icon: Icon( + _recording ? Icons.stop : Icons.mic, + color: _loading + ? Theme.of(context).colorScheme.secondary + : Theme.of(context).colorScheme.primary, + ), + ), + if (!_loading) + IconButton( + onPressed: () async { + await _sendTextPrompt(_textController.text); + }, + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ), + ); + } + + final lightControlTool = FunctionDeclaration( + 'setLightValues', + 'Set the brightness and color temperature of a room light.', + parameters: { + 'brightness': Schema.integer( + description: 'Light level from 0 to 100. ' + 'Zero is off and 100 is full brightness.', + ), + 'colorTemperature': Schema.string( + description: 'Color temperature of the light fixture, ' + 'which can be `daylight`, `cool` or `warm`.', + ), + }, + ); + + Future> _setLightValues({ + int? brightness, + String? colorTemperature, + }) async { + final apiResponse = { + 'colorTemprature': 'warm', + 'brightness': brightness, + }; + return apiResponse; + } + + Future _setupSession() async { + setState(() { + _loading = true; + }); + + if (!_sessionOpening) { + _session = await _liveModel.connect(); + _sessionOpening = true; + _stopController = StreamController(); + unawaited( + processMessagesContinuously( + stopSignal: _stopController, + ), + ); + } else { + _stopController.add(true); + await _stopController.close(); + + await _session.close(); + await _audioManager.stopAudioPlayer(); + await _audioManager.disposeAudioPlayer(); + _sessionOpening = false; + } + + setState(() { + _loading = false; + }); + } + + Future _startRecording() async { + setState(() { + _recording = true; + }); + try { + await _audioRecorder.checkPermission(); + final audioRecordStream = _audioRecorder.startRecordingStream(); + // Map the Uint8List stream to InlineDataPart stream + final mediaChunkStream = audioRecordStream.map((data) { + return InlineDataPart('audio/pcm', data); + }); + await _session.sendMediaStream(mediaChunkStream); + } catch (e) { + _showError(e.toString()); + } + } + + Future _stopRecording() async { + try { + await _audioRecorder.stopRecording(); + } catch (e) { + _showError(e.toString()); + } + + setState(() { + _recording = false; + }); + } + + Future _sendTextPrompt(String textPrompt) async { + setState(() { + _loading = true; + }); + try { + final prompt = Content.text(textPrompt); + await _session.send(input: prompt, turnComplete: true); + } catch (e) { + _showError(e.toString()); + } + + setState(() { + _loading = false; + }); + } + + Future processMessagesContinuously({ + required StreamController stopSignal, + }) async { + bool shouldContinue = true; + + //listen to the stop signal stream + stopSignal.stream.listen((stop) { + if (stop) { + shouldContinue = false; + } + }); + + while (shouldContinue) { + try { + await for (final message in _session.receive()) { + // Process the received message + await _handleLiveServerMessage(message); + } + } catch (e) { + _showError(e.toString()); + break; + } + + // Optionally add a delay before restarting, if needed + await Future.delayed( + const Duration(milliseconds: 100), + ); // Small delay to prevent tight loops + } + } + + Future _handleLiveServerMessage(LiveServerResponse response) async { + final message = response.message; + + if (message is LiveServerContent) { + if (message.modelTurn != null) { + await _handleLiveServerContent(message); + } + if (message.turnComplete != null && message.turnComplete!) { + await _handleTurnComplete(); + } + if (message.interrupted != null && message.interrupted!) { + log('Interrupted: $response'); + } + } else if (message is LiveServerToolCall && message.functionCalls != null) { + await _handleLiveServerToolCall(message); + } + } + + Future _handleLiveServerContent(LiveServerContent response) async { + final partList = response.modelTurn?.parts; + if (partList != null) { + for (final part in partList) { + if (part is TextPart) { + await _handleTextPart(part); + } else if (part is InlineDataPart) { + await _handleInlineDataPart(part); + } else { + log('receive part with type ${part.runtimeType}'); + } + } + } + } + + Future _handleTextPart(TextPart part) async { + if (!_loading) { + setState(() { + _loading = true; + }); + } + _messages.add(MessageData(text: part.text, fromUser: false)); + setState(() { + _loading = false; + _scrollDown(); + }); + } + + Future _handleInlineDataPart(InlineDataPart part) async { + if (part.mimeType.startsWith('audio')) { + _chunkBuilder.add(part.bytes); + _audioIndex++; + if (_audioIndex == 15) { + Uint8List chunk = await audioChunkWithHeader( + _chunkBuilder.toBytes(), + 24000, + ); + _audioManager.addAudio(chunk); + _chunkBuilder.clear(); + _audioIndex = 0; + } + } + } + + Future _handleTurnComplete() async { + if (_chunkBuilder.isNotEmpty) { + Uint8List chunk = await audioChunkWithHeader( + _chunkBuilder.toBytes(), + 24000, + ); + _audioManager.addAudio(chunk); + _audioIndex = 0; + _chunkBuilder.clear(); + } + } + + Future _handleLiveServerToolCall(LiveServerToolCall response) async { + final functionCalls = response.functionCalls!.toList(); + if (functionCalls.isNotEmpty) { + final functionCall = functionCalls.first; + if (functionCall.name == 'setLightValues') { + var color = functionCall.args['colorTemperature']! as String; + var brightness = functionCall.args['brightness']! as int; + final functionResult = await _setLightValues( + brightness: brightness, + colorTemperature: color, + ); + await _session.send( + input: Content.functionResponse(functionCall.name, functionResult), + ); + } else { + throw UnimplementedError( + 'Function not declared to the model: ${functionCall.name}', + ); + } + } + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: SelectableText(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/chat_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/chat_page.dart new file mode 100644 index 000000000000..df0afea88482 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/chat_page.dart @@ -0,0 +1,176 @@ +// Copyright 2025 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/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../widgets/message_widget.dart'; + +class ChatPage extends StatefulWidget { + const ChatPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _ChatPageState(); +} + +class _ChatPageState extends State { + ChatSession? _chat; + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _messages = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + _chat = widget.model.startChat(); + } + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration( + milliseconds: 750, + ), + curve: Curves.easeOutCirc, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + image: _messages[idx].image, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + controller: _textController, + onSubmitted: _sendChatMessage, + ), + ), + const SizedBox.square( + dimension: 15, + ), + if (!_loading) + IconButton( + onPressed: () async { + await _sendChatMessage(_textController.text); + }, + icon: Icon( + Icons.send, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ), + ); + } + + Future _sendChatMessage(String message) async { + setState(() { + _loading = true; + }); + + try { + _messages.add(MessageData(text: message, fromUser: true)); + var response = await _chat?.sendMessage( + Content.text(message), + ); + var text = response?.text; + _messages.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + return; + } else { + setState(() { + _loading = false; + _scrollDown(); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _loading = false; + }); + } finally { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + } + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: SelectableText(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/document.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/document.dart new file mode 100644 index 000000000000..ec5114e8b13a --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/document.dart @@ -0,0 +1,117 @@ +// Copyright 2025 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/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/services.dart'; +import '../widgets/message_widget.dart'; + +class DocumentPage extends StatefulWidget { + const DocumentPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _DocumentPageState(); +} + +class _DocumentPageState extends State { + ChatSession? chat; + late final GenerativeModel model; + final List _messages = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + chat = widget.model.startChat(); + } + + Future _testDocumentReading(model) async { + try { + ByteData docBytes = + await rootBundle.load('assets/documents/gemini_summary.pdf'); + + const _prompt = + 'Write me a summary in one sentence what this document is about.'; + + final prompt = TextPart(_prompt); + + setState(() { + _messages.add(MessageData(text: _prompt, fromUser: true)); + }); + + final pdfPart = + InlineDataPart('application/pdf', docBytes.buffer.asUint8List()); + + final response = await widget.model.generateContent([ + Content.multi([prompt, pdfPart]), + ]); + + setState(() { + _messages.add(MessageData(text: response.text, fromUser: false)); + }); + } catch (e) { + print('Error sending document to model: $e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Center( + child: SizedBox( + child: ElevatedButton( + onPressed: !_loading + ? () async { + await _testDocumentReading(widget.model); + } + : null, + child: const Text('Test Document Reading'), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/function_calling_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/function_calling_page.dart new file mode 100644 index 000000000000..cf79b61a7104 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/function_calling_page.dart @@ -0,0 +1,200 @@ +// Copyright 2025 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/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../widgets/message_widget.dart'; + +class FunctionCallingPage extends StatefulWidget { + const FunctionCallingPage({ + super.key, + required this.title, + required this.useVertexBackend, + }); + + final String title; + final bool useVertexBackend; + + @override + State createState() => _FunctionCallingPageState(); +} + +class Location { + final String city; + final String state; + + Location(this.city, this.state); +} + +class _FunctionCallingPageState extends State { + late final GenerativeModel _functionCallModel; + final List _messages = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + if (widget.useVertexBackend) { + var vertexAI = FirebaseAI.vertexAI(auth: FirebaseAuth.instance); + _functionCallModel = vertexAI.generativeModel( + model: 'gemini-2.0-flash', + tools: [ + Tool.functionDeclarations([fetchWeatherTool]), + ], + ); + } else { + var googleAI = FirebaseAI.googleAI(auth: FirebaseAuth.instance); + _functionCallModel = googleAI.generativeModel( + model: 'gemini-2.0-flash', + tools: [ + Tool.functionDeclarations([fetchWeatherTool]), + ], + ); + } + } + + // This is a hypothetical API to return a fake weather data collection for + // certain location + Future> fetchWeather( + Location location, + String date, + ) async { + // TODO(developer): Call a real weather API. + // Mock response from the API. In developer live code this would call the + // external API and return what that API returns. + final apiResponse = { + 'temperature': 38, + 'chancePrecipitation': '56%', + 'cloudConditions': 'partly-cloudy', + }; + return apiResponse; + } + + /// Actual function to demonstrate the function calling feature. + final fetchWeatherTool = FunctionDeclaration( + 'fetchWeather', + 'Get the weather conditions for a specific city on a specific date.', + parameters: { + 'location': Schema.object( + description: 'The name of the city and its state for which to get ' + 'the weather. Only cities in the USA are supported.', + properties: { + 'city': Schema.string( + description: 'The city of the location.', + ), + 'state': Schema.string( + description: 'The state of the location.', + ), + }, + ), + 'date': Schema.string( + description: 'The date for which to get the weather. ' + 'Date must be in the format: YYYY-MM-DD.', + ), + }, + ); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: !_loading + ? () async { + await _testFunctionCalling(); + } + : null, + child: const Text('Test Function Calling'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future _testFunctionCalling() async { + setState(() { + _loading = true; + }); + final functionCallChat = _functionCallModel.startChat(); + const prompt = 'What is the weather like in Boston on 10/02 in year 2024?'; + + // Send the message to the generative model. + var response = await functionCallChat.sendMessage( + Content.text(prompt), + ); + + final functionCalls = response.functionCalls.toList(); + // When the model response with a function call, invoke the function. + if (functionCalls.isNotEmpty) { + final functionCall = functionCalls.first; + if (functionCall.name == 'fetchWeather') { + Map location = + functionCall.args['location']! as Map; + var date = functionCall.args['date']! as String; + var city = location['city'] as String; + var state = location['state'] as String; + final functionResult = await fetchWeather(Location(city, state), date); + // Send the response to the model so that it can use the result to + // generate text for the user. + response = await functionCallChat.sendMessage( + Content.functionResponse(functionCall.name, functionResult), + ); + } else { + throw UnimplementedError( + 'Function not declared to the model: ${functionCall.name}', + ); + } + } + // When the model responds with non-null text content, print it. + if (response.text case final text?) { + _messages.add(MessageData(text: text)); + setState(() { + _loading = false; + }); + } + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart new file mode 100644 index 000000000000..5dff25a2efe1 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/image_prompt_page.dart @@ -0,0 +1,243 @@ +// Copyright 2025 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/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/services.dart'; +import '../widgets/message_widget.dart'; + +class ImagePromptPage extends StatefulWidget { + const ImagePromptPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _ImagePromptPageState(); +} + +class _ImagePromptPageState extends State { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _generatedContent = []; + bool _loading = false; + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration( + milliseconds: 750, + ), + curve: Curves.easeOutCirc, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + var content = _generatedContent[idx]; + return MessageWidget( + text: content.text, + image: content.image, + isFromUser: content.fromUser ?? false, + ); + }, + itemCount: _generatedContent.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + controller: _textController, + ), + ), + const SizedBox.square( + dimension: 15, + ), + if (!_loading) + IconButton( + onPressed: () async { + await _sendImagePrompt(_textController.text); + }, + icon: Icon( + Icons.image, + color: Theme.of(context).colorScheme.primary, + ), + ), + if (!_loading) + IconButton( + onPressed: () async { + await _sendStorageUriPrompt(_textController.text); + }, + icon: Icon( + Icons.storage, + color: Theme.of(context).colorScheme.primary, + ), + ) + else + const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ), + ); + } + + Future _sendImagePrompt(String message) async { + setState(() { + _loading = true; + }); + try { + ByteData catBytes = await rootBundle.load('assets/images/cat.jpg'); + ByteData sconeBytes = await rootBundle.load('assets/images/scones.jpg'); + final content = [ + Content.multi([ + TextPart(message), + // The only accepted mime types are image/*. + InlineDataPart('image/jpeg', catBytes.buffer.asUint8List()), + InlineDataPart('image/jpeg', sconeBytes.buffer.asUint8List()), + ]), + ]; + _generatedContent.add( + MessageData( + image: Image.asset('assets/images/cat.jpg'), + text: message, + fromUser: true, + ), + ); + _generatedContent.add( + MessageData( + image: Image.asset('assets/images/scones.jpg'), + fromUser: true, + ), + ); + + var response = await widget.model.generateContent(content); + var text = response.text; + _generatedContent.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + return; + } else { + setState(() { + _loading = false; + _scrollDown(); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _loading = false; + }); + } finally { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + } + } + + Future _sendStorageUriPrompt(String message) async { + setState(() { + _loading = true; + }); + try { + final content = [ + Content.multi([ + TextPart(message), + FileData( + 'image/jpeg', + 'gs://vertex-ai-example-ef5a2.appspot.com/foodpic.jpg', + ), + ]), + ]; + _generatedContent.add(MessageData(text: message, fromUser: true)); + + var response = await widget.model.generateContent(content); + var text = response.text; + _generatedContent.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + return; + } else { + setState(() { + _loading = false; + _scrollDown(); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _loading = false; + }); + } finally { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + } + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: SelectableText(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/imagen_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/imagen_page.dart new file mode 100644 index 000000000000..c957f207278e --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/imagen_page.dart @@ -0,0 +1,214 @@ +// Copyright 2025 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/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +//import 'package:firebase_storage/firebase_storage.dart'; +import '../widgets/message_widget.dart'; + +class ImagenPage extends StatefulWidget { + const ImagenPage({ + super.key, + required this.title, + required this.model, + }); + + final String title; + final ImagenModel model; + + @override + State createState() => _ImagenPageState(); +} + +class _ImagenPageState extends State { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _generatedContent = []; + bool _loading = false; + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration( + milliseconds: 750, + ), + curve: Curves.easeOutCirc, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _generatedContent[idx].text, + image: _generatedContent[idx].image, + isFromUser: _generatedContent[idx].fromUser ?? false, + ); + }, + itemCount: _generatedContent.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: true, + focusNode: _textFieldFocus, + controller: _textController, + ), + ), + const SizedBox.square( + dimension: 15, + ), + if (!_loading) + IconButton( + onPressed: () async { + await _testImagen(_textController.text); + }, + icon: Icon( + Icons.image_search, + color: Theme.of(context).colorScheme.primary, + ), + tooltip: 'Imagen raw data', + ) + else + const CircularProgressIndicator(), + // NOTE: Keep this API private until future release. + // if (!_loading) + // IconButton( + // onPressed: () async { + // await _testImagenGCS(_textController.text); + // }, + // icon: Icon( + // Icons.imagesearch_roller, + // color: Theme.of(context).colorScheme.primary, + // ), + // tooltip: 'Imagen GCS', + // ) + // else + // const CircularProgressIndicator(), + ], + ), + ), + ], + ), + ), + ); + } + + Future _testImagen(String prompt) async { + setState(() { + _loading = true; + }); + + try { + var response = await widget.model.generateImages(prompt); + + if (response.images.isNotEmpty) { + var imagenImage = response.images[0]; + + _generatedContent.add( + MessageData( + image: Image.memory(imagenImage.bytesBase64Encoded), + text: prompt, + fromUser: false, + ), + ); + } else { + // Handle the case where no images were generated + _showError('Error: No images were generated.'); + } + } catch (e) { + _showError(e.toString()); + } + + setState(() { + _loading = false; + _scrollDown(); + }); + } + // NOTE: Keep this API private until future release. + // Future _testImagenGCS(String prompt) async { + // setState(() { + // _loading = true; + // }); + // var gcsUrl = 'gs://vertex-ai-example-ef5a2.appspot.com/imagen'; + + // var response = await widget.model.generateImagesGCS(prompt, gcsUrl); + + // if (response.images.isNotEmpty) { + // var imagenImage = response.images[0]; + // final returnImageUri = imagenImage.gcsUri; + // final reference = FirebaseStorage.instance.refFromURL(returnImageUri); + // final downloadUrl = await reference.getDownloadURL(); + // // Process the image + // _generatedContent.add( + // MessageData( + // image: Image(image: NetworkImage(downloadUrl)), + // text: prompt, + // fromUser: false, + // ), + // ); + // } else { + // // Handle the case where no images were generated + // _showError('Error: No images were generated.'); + // } + // setState(() { + // _loading = false; + // }); + // } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: SelectableText(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart new file mode 100644 index 000000000000..fcbdef64499e --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/schema_page.dart @@ -0,0 +1,182 @@ +// Copyright 2025 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/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../widgets/message_widget.dart'; + +class SchemaPromptPage extends StatefulWidget { + const SchemaPromptPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _SchemaPromptPageState(); +} + +class _SchemaPromptPageState extends State { + final ScrollController _scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + final FocusNode _textFieldFocus = FocusNode(); + final List _messages = []; + bool _loading = false; + + void _scrollDown() { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration( + milliseconds: 750, + ), + curve: Curves.easeOutCirc, + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + controller: _scrollController, + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: !_loading + ? () async { + await _promptSchemaTest(); + } + : null, + child: const Text('Schema Prompt'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future _promptSchemaTest() async { + setState(() { + _loading = true; + }); + try { + final content = [ + Content.text( + "For use in a children's card game, generate 10 animal-based " + 'characters.', + ), + ]; + + final jsonSchema = Schema.object( + properties: { + 'characters': Schema.array( + items: Schema.object( + properties: { + 'name': Schema.string(), + 'age': Schema.integer(), + 'species': Schema.string(), + 'accessory': + Schema.enumString(enumValues: ['hat', 'belt', 'shoes']), + }, + ), + ), + }, + optionalProperties: ['accessory'], + ); + + final response = await widget.model.generateContent( + content, + generationConfig: GenerationConfig( + responseMimeType: 'application/json', + responseSchema: jsonSchema, + ), + ); + + var text = response.text; + _messages.add(MessageData(text: text, fromUser: false)); + + if (text == null) { + _showError('No response from API.'); + return; + } else { + setState(() { + _loading = false; + _scrollDown(); + }); + } + } catch (e) { + _showError(e.toString()); + setState(() { + _loading = false; + }); + } finally { + _textController.clear(); + setState(() { + _loading = false; + }); + _textFieldFocus.requestFocus(); + } + } + + void _showError(String message) { + showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Something went wrong'), + content: SingleChildScrollView( + child: SelectableText(message), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('OK'), + ), + ], + ); + }, + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart new file mode 100644 index 000000000000..8e2455d5d429 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/token_count_page.dart @@ -0,0 +1,106 @@ +// Copyright 2025 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/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import '../widgets/message_widget.dart'; + +class TokenCountPage extends StatefulWidget { + const TokenCountPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _TokenCountPageState(); +} + +class _TokenCountPageState extends State { + final List _messages = []; + bool _loading = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Row( + children: [ + Expanded( + child: ElevatedButton( + onPressed: !_loading + ? () async { + await _testCountToken(); + } + : null, + child: const Text('Count Tokens'), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future _testCountToken() async { + setState(() { + _loading = true; + }); + + const prompt = 'tell a short story'; + final content = Content.text(prompt); + final tokenResponse = await widget.model.countTokens([content]); + final tokenResult = 'Count token: ${tokenResponse.totalTokens}, billable ' + 'characters: ${tokenResponse.totalBillableCharacters}'; + _messages.add(MessageData(text: tokenResult, fromUser: false)); + + final contentResponse = await widget.model.generateContent([content]); + final contentMetaData = 'result metadata, promptTokenCount:' + '${contentResponse.usageMetadata!.promptTokenCount}, ' + 'candidatesTokenCount:' + '${contentResponse.usageMetadata!.candidatesTokenCount}, ' + 'totalTokenCount:' + '${contentResponse.usageMetadata!.totalTokenCount}'; + _messages.add(MessageData(text: contentMetaData, fromUser: false)); + setState(() { + _loading = false; + }); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/pages/video_page.dart b/packages/firebase_ai/firebase_ai/example/lib/pages/video_page.dart new file mode 100644 index 000000000000..0a98c9a82486 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/pages/video_page.dart @@ -0,0 +1,116 @@ +// Copyright 2025 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/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:flutter/services.dart'; +import '../widgets/message_widget.dart'; + +class VideoPage extends StatefulWidget { + const VideoPage({super.key, required this.title, required this.model}); + + final String title; + final GenerativeModel model; + + @override + State createState() => _VideoPageState(); +} + +class _VideoPageState extends State { + ChatSession? chat; + late final GenerativeModel model; + final List _messages = []; + bool _loading = false; + + @override + void initState() { + super.initState(); + chat = widget.model.startChat(); + } + + Future _testVideo(model) async { + try { + ByteData videoBytes = + await rootBundle.load('assets/videos/landscape.mp4'); + + const _prompt = 'Can you tell me what is in the video?'; + + final prompt = TextPart(_prompt); + + setState(() { + _messages.add(MessageData(text: _prompt, fromUser: true)); + }); + + final videoPart = + InlineDataPart('video/mp4', videoBytes.buffer.asUint8List()); + + final response = await widget.model.generateContent([ + Content.multi([prompt, videoPart]), + ]); + + setState(() { + _messages.add(MessageData(text: response.text, fromUser: false)); + }); + } catch (e) { + print('Error sending video to model: $e'); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.title), + ), + body: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ListView.builder( + itemBuilder: (context, idx) { + return MessageWidget( + text: _messages[idx].text, + isFromUser: _messages[idx].fromUser ?? false, + ); + }, + itemCount: _messages.length, + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 25, + horizontal: 15, + ), + child: Center( + child: SizedBox( + child: ElevatedButton( + onPressed: !_loading + ? () async { + await _testVideo(widget.model); + } + : null, + child: const Text('Test Video Prompt'), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/utils/audio_player.dart b/packages/firebase_ai/firebase_ai/example/lib/utils/audio_player.dart new file mode 100644 index 000000000000..3c5559481ed7 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/utils/audio_player.dart @@ -0,0 +1,143 @@ +// Copyright 2025 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 'dart:typed_data'; +import 'dart:async'; + +import 'package:just_audio/just_audio.dart'; + +/// Creates a WAV audio chunk with a properly formatted header. +Future audioChunkWithHeader( + List data, + int sampleRate, +) async { + var channels = 1; + + int byteRate = ((16 * sampleRate * channels) / 8).round(); + + var size = data.length; + var fileSize = size + 36; + + Uint8List header = Uint8List.fromList([ + // "RIFF" + 82, 73, 70, 70, + fileSize & 0xff, + (fileSize >> 8) & 0xff, + (fileSize >> 16) & 0xff, + (fileSize >> 24) & 0xff, + // WAVE + 87, 65, 86, 69, + // fmt + 102, 109, 116, 32, + // fmt chunk size 16 + 16, 0, 0, 0, + // Type of format + 1, 0, + // One channel + channels, 0, + // Sample rate + sampleRate & 0xff, + (sampleRate >> 8) & 0xff, + (sampleRate >> 16) & 0xff, + (sampleRate >> 24) & 0xff, + // Byte rate + byteRate & 0xff, + (byteRate >> 8) & 0xff, + (byteRate >> 16) & 0xff, + (byteRate >> 24) & 0xff, + // Uhm + ((16 * channels) / 8).round(), 0, + // bitsize + 16, 0, + // "data" + 100, 97, 116, 97, + size & 0xff, + (size >> 8) & 0xff, + (size >> 16) & 0xff, + (size >> 24) & 0xff, + // incoming data + ...data, + ]); + return header; +} + +class ByteStreamAudioSource extends StreamAudioSource { + ByteStreamAudioSource(this.bytes) : super(tag: 'Byte Stream Audio'); + + final Uint8List bytes; + + @override + Future request([int? start, int? end]) async { + start ??= 0; + end ??= bytes.length; + return StreamAudioResponse( + sourceLength: bytes.length, + contentLength: end - start, + offset: start, + stream: Stream.value(bytes.sublist(start, end)), + contentType: 'audio/wav', // Or the appropriate content type + ); + } +} + +class AudioStreamManager { + final _audioPlayer = AudioPlayer(); + final _audioChunkController = StreamController(); + var _audioSource = ConcatenatingAudioSource( + children: [], + ); + + AudioStreamManager() { + _initAudioPlayer(); + } + + Future _initAudioPlayer() async { + // 1. Create a ConcatenatingAudioSource to handle the stream + await _audioPlayer.setAudioSource(_audioSource); + + // 2. Listen to the stream of audio chunks + _audioChunkController.stream.listen(_addAudioChunk); + + await _audioPlayer.play(); // Start playing (even if initially empty) + + _audioPlayer.processingStateStream.listen((state) async { + if (state == ProcessingState.completed) { + await _audioPlayer + .pause(); // Or player.stop() if you want to release resources + await _audioPlayer.seek(Duration.zero, index: 0); + await _audioSource.clear(); + await _audioPlayer.play(); + } + }); + } + + Future _addAudioChunk(Uint8List chunk) async { + var buffer = ByteStreamAudioSource(chunk); + + await _audioSource.add(buffer); + } + + void addAudio(Uint8List chunk) { + _audioChunkController.add(chunk); + } + + Future stopAudioPlayer() async { + await _audioPlayer.stop(); + } + + Future disposeAudioPlayer() async { + await _audioPlayer.dispose(); + await _audioChunkController.close(); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/utils/audio_recorder.dart b/packages/firebase_ai/firebase_ai/example/lib/utils/audio_recorder.dart new file mode 100644 index 000000000000..1f3710cd0c8f --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/utils/audio_recorder.dart @@ -0,0 +1,245 @@ +// Copyright 2025 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 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +/// An exception thrown when microphone permission is denied or not granted. +class MicrophonePermissionDeniedException implements Exception { + /// The optional message associated with the permission denial. + final String? message; + + /// Creates a new [MicrophonePermissionDeniedException] with an optional [message]. + MicrophonePermissionDeniedException([this.message]); + + @override + String toString() { + if (message == null) { + return 'MicrophonePermissionDeniedException'; + } + return 'MicrophonePermissionDeniedException: $message'; + } +} + +class Resampler { + /// Resamples 16-bit integer PCM audio data from a source sample rate to a + /// target sample rate using linear interpolation. + /// + /// [sourceRate]: The sample rate of the input audio data. + /// [targetRate]: The desired sample rate of the output audio data. + /// [input]: The input audio data as a Uint8List containing 16-bit PCM samples. + /// + /// Returns a new Uint8List containing 16-bit PCM samples resampled to the + /// target rate. + static Uint8List resampleLinear16( + int sourceRate, + int targetRate, + Uint8List input, + ) { + if (sourceRate == targetRate) return input; // No resampling needed + + final outputLength = (input.length * targetRate / sourceRate).round(); + final output = Uint8List(outputLength); + final inputData = Int16List.view(input.buffer); + final outputData = Int16List.view(output.buffer); + + for (int i = 0; i < outputLength ~/ 2; i++) { + final sourcePosition = i * sourceRate / targetRate; + final index1 = sourcePosition.floor(); + final index2 = index1 + 1; + final weight2 = sourcePosition - index1; + final weight1 = 1.0 - weight2; + + // Ensure indices are within the valid range + final sample1 = inputData[index1.clamp(0, inputData.length - 1)]; + final sample2 = inputData[index2.clamp(0, inputData.length - 1)]; + + // Interpolate and convert back to 16-bit integer + final interpolatedSample = + (sample1 * weight1 + sample2 * weight2).toInt(); + + outputData[i] = interpolatedSample; + } + + return output; + } +} + +class InMemoryAudioRecorder { + final _audioChunks = []; + final _recorder = AudioRecorder(); + StreamSubscription? _recordSubscription; + late String? _lastAudioPath; + AudioEncoder _encoder = AudioEncoder.pcm16bits; + + Future _getPath() async { + String suffix; + if (_encoder == AudioEncoder.pcm16bits) { + suffix = 'pcm'; + } else if (_encoder == AudioEncoder.aacLc) { + suffix = 'm4a'; + } else { + suffix = 'wav'; + } + final dir = await getDownloadsDirectory(); + final path = + '${dir!.path}/audio_${DateTime.now().millisecondsSinceEpoch}.$suffix'; + return path; + } + + Future checkPermission() async { + final hasPermission = await _recorder.hasPermission(); + if (!hasPermission) { + throw MicrophonePermissionDeniedException('Not having mic permission'); + } + } + + Future _isEncoderSupported(AudioEncoder encoder) async { + final isSupported = await _recorder.isEncoderSupported( + encoder, + ); + + if (!isSupported) { + debugPrint('${encoder.name} is not supported on this platform.'); + debugPrint('Supported encoders are:'); + + for (final e in AudioEncoder.values) { + if (await _recorder.isEncoderSupported(e)) { + debugPrint('- ${e.name}'); + } + } + } + + return isSupported; + } + + Future startRecording({bool fromFile = false}) async { + if (!await _isEncoderSupported(_encoder)) { + return; + } + var recordConfig = RecordConfig( + encoder: _encoder, + sampleRate: 16000, + numChannels: 1, + androidConfig: const AndroidRecordConfig( + muteAudio: true, + audioSource: AndroidAudioSource.mic, + ), + ); + final devs = await _recorder.listInputDevices(); + debugPrint(devs.toString()); + _lastAudioPath = await _getPath(); + if (fromFile) { + await _recorder.start(recordConfig, path: _lastAudioPath!); + } else { + final stream = await _recorder.startStream(recordConfig); + _recordSubscription = stream.listen(_audioChunks.add); + } + } + + Future startRecordingFile() async { + if (!await _isEncoderSupported(_encoder)) { + return; + } + var recordConfig = RecordConfig( + encoder: _encoder, + sampleRate: 16000, + numChannels: 1, + ); + final devs = await _recorder.listInputDevices(); + debugPrint(devs.toString()); + _lastAudioPath = await _getPath(); + await _recorder.start(recordConfig, path: _lastAudioPath!); + } + + Stream startRecordingStream() async* { + if (!await _isEncoderSupported(_encoder)) { + return; + } + var recordConfig = RecordConfig( + encoder: _encoder, + sampleRate: 16000, + numChannels: 1, + ); + final devices = await _recorder.listInputDevices(); + debugPrint(devices.toString()); + final stream = await _recorder.startStream(recordConfig); + + await for (final data in stream) { + yield data; + } + } + + Future stopRecording() async { + await _recordSubscription?.cancel(); + _recordSubscription = null; + + await _recorder.stop(); + } + + Future fetchAudioBytes({ + bool fromFile = false, + bool removeHeader = false, + }) async { + Uint8List resultBytes; + if (fromFile) { + resultBytes = await _getAudioBytesFromFile(_lastAudioPath!); + } else { + final builder = BytesBuilder(); + _audioChunks.forEach(builder.add); + resultBytes = builder.toBytes(); + } + + // resample + resultBytes = Resampler.resampleLinear16(44100, 16000, resultBytes); + final dir = await getDownloadsDirectory(); + final path = '${dir!.path}/audio_resampled.pcm'; + final file = File(path); + final sink = file.openWrite(); + + sink.add(resultBytes); + + await sink.close(); + return resultBytes; + } + + Future _removeWavHeader(Uint8List audio) async { + // Assuming a standard WAV header size of 44 bytes + const wavHeaderSize = 44; + final audioData = audio.sublist(wavHeaderSize); + return audioData; + } + + Future _getAudioBytesFromFile( + String filePath, { + bool removeHeader = false, + }) async { + final file = File(_lastAudioPath!); + + if (!file.existsSync()) { + throw Exception('Audio file not found: ${file.path}'); + } + + var pcmBytes = await file.readAsBytes(); + if (removeHeader) { + pcmBytes = await _removeWavHeader(pcmBytes); + } + return pcmBytes; + } +} diff --git a/packages/firebase_ai/firebase_ai/example/lib/widgets/message_widget.dart b/packages/firebase_ai/firebase_ai/example/lib/widgets/message_widget.dart new file mode 100644 index 000000000000..b8a0f23ce03b --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/lib/widgets/message_widget.dart @@ -0,0 +1,68 @@ +// Copyright 2025 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/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; + +class MessageData { + MessageData({this.image, this.text, this.fromUser}); + final Image? image; + final String? text; + final bool? fromUser; +} + +class MessageWidget extends StatelessWidget { + final Image? image; + final String? text; + final bool isFromUser; + + const MessageWidget({ + super.key, + this.image, + this.text, + required this.isFromUser, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: + isFromUser ? MainAxisAlignment.end : MainAxisAlignment.start, + children: [ + Flexible( + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + decoration: BoxDecoration( + color: isFromUser + ? Theme.of(context).colorScheme.primaryContainer + : Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(18), + ), + padding: const EdgeInsets.symmetric( + vertical: 15, + horizontal: 20, + ), + margin: const EdgeInsets.only(bottom: 8), + child: Column( + children: [ + if (text case final text?) MarkdownBody(data: text), + if (image case final image?) image, + ], + ), + ), + ), + ], + ); + } +} diff --git a/packages/firebase_ai/firebase_ai/example/macos/.gitignore b/packages/firebase_ai/firebase_ai/example/macos/.gitignore new file mode 100644 index 000000000000..746adbb6b9e1 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/firebase_ai/firebase_ai/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/firebase_ai/firebase_ai/example/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 000000000000..4b81f9b2d200 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/firebase_ai/firebase_ai/example/macos/Flutter/Flutter-Release.xcconfig b/packages/firebase_ai/firebase_ai/example/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 000000000000..5caa9d1579e4 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/firebase_ai/firebase_ai/example/macos/Podfile b/packages/firebase_ai/firebase_ai/example/macos/Podfile new file mode 100644 index 000000000000..b52666a10389 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Podfile @@ -0,0 +1,43 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/project.pbxproj b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000000..4bc66a519ca5 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,805 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 1E1464098F5197FB1E35FDA1 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0E05DB31CC6D204C7C78D127 /* Pods_RunnerTests.framework */; }; + 20C13FC2C906153EF4A40292 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 08B0491E23641E5BA5DD096C /* GoogleService-Info.plist */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 3D1CF19370CB8E26E5C667A5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C4DAA18FE8B79A454BF3F8CB /* Pods_Runner.framework */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 08B0491E23641E5BA5DD096C /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + 0E05DB31CC6D204C7C78D127 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 3A40C9AE19ACEC6C433878E9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 587C61AFC0E2B0BF5340F8E8 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 5C2B5E4F1CE100E1FA5D9DC5 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 766A2E414AFDFA56243527A6 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 816B0EE72BF94FC5261D04E6 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + A2911B8EF91B3925874FDE6A /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + C4DAA18FE8B79A454BF3F8CB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 1E1464098F5197FB1E35FDA1 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 3D1CF19370CB8E26E5C667A5 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 08B0491E23641E5BA5DD096C /* GoogleService-Info.plist */, + BE277C424FC00920BE07E371 /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + BE277C424FC00920BE07E371 /* Pods */ = { + isa = PBXGroup; + children = ( + A2911B8EF91B3925874FDE6A /* Pods-Runner.debug.xcconfig */, + 816B0EE72BF94FC5261D04E6 /* Pods-Runner.release.xcconfig */, + 766A2E414AFDFA56243527A6 /* Pods-Runner.profile.xcconfig */, + 3A40C9AE19ACEC6C433878E9 /* Pods-RunnerTests.debug.xcconfig */, + 587C61AFC0E2B0BF5340F8E8 /* Pods-RunnerTests.release.xcconfig */, + 5C2B5E4F1CE100E1FA5D9DC5 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C4DAA18FE8B79A454BF3F8CB /* Pods_Runner.framework */, + 0E05DB31CC6D204C7C78D127 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 33B83C0D35C3606AED8215FE /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + E10F886575A4AF9F1D3D5C5B /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + 1D3525FBE401B81EB0265948 /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* example.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + 20C13FC2C906153EF4A40292 /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 1D3525FBE401B81EB0265948 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33B83C0D35C3606AED8215FE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; + E10F886575A4AF9F1D3D5C5B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3A40C9AE19ACEC6C433878E9 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 587C61AFC0E2B0BF5340F8E8 /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 5C2B5E4F1CE100E1FA5D9DC5 /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000000..b0a82f087ad1 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000000..21a3cc14c74e --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000000..18d981003d68 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/AppDelegate.swift b/packages/firebase_ai/firebase_ai/example/macos/Runner/AppDelegate.swift new file mode 100644 index 000000000000..b3c176141221 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000000..a2ec33f19f11 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 000000000000..82b6f9d9a33e Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 000000000000..13b35eba55c6 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 000000000000..0a3f5fa40fb3 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 000000000000..bdb57226d5f2 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 000000000000..f083318e09ca Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 000000000000..326c0e72c9d8 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 000000000000..2f1632cfddf3 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Base.lproj/MainMenu.xib b/packages/firebase_ai/firebase_ai/example/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 000000000000..80e867a4e06b --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 000000000000..92fb3cd54e84 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = example + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.example + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2024 com.example. All rights reserved. diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Debug.xcconfig b/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 000000000000..36b0fd9464f4 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Release.xcconfig b/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 000000000000..dff4f49561c8 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Warnings.xcconfig b/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 000000000000..42bcbf4780b1 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/DebugProfile.entitlements b/packages/firebase_ai/firebase_ai/example/macos/Runner/DebugProfile.entitlements new file mode 100644 index 000000000000..b4bd9ee174a1 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,18 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.files.downloads.read-write + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.device.audio-input + + + diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Info.plist b/packages/firebase_ai/firebase_ai/example/macos/Runner/Info.plist new file mode 100644 index 000000000000..a81b3fd0d617 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/Info.plist @@ -0,0 +1,34 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + NSMicrophoneUsageDescription + Permission to Record audio + + diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/MainFlutterWindow.swift b/packages/firebase_ai/firebase_ai/example/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 000000000000..3cc05eb23491 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/firebase_ai/firebase_ai/example/macos/Runner/Release.entitlements b/packages/firebase_ai/firebase_ai/example/macos/Runner/Release.entitlements new file mode 100644 index 000000000000..2f9659c917fb --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.downloads.read-write + + + diff --git a/packages/firebase_ai/firebase_ai/example/macos/firebase_app_id_file.json b/packages/firebase_ai/firebase_ai/example/macos/firebase_app_id_file.json new file mode 100644 index 000000000000..f4a21e85e553 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/macos/firebase_app_id_file.json @@ -0,0 +1,7 @@ +{ + "file_generated_by": "FlutterFire CLI", + "purpose": "FirebaseAppID & ProjectID for this Firebase app in this directory", + "GOOGLE_APP_ID": "1:651313571784:ios:722e4f9cad0b9e5db1c2fd", + "FIREBASE_PROJECT_ID": "vertex-ai-example-ef5a2", + "GCM_SENDER_ID": "651313571784" +} \ No newline at end of file diff --git a/packages/firebase_ai/firebase_ai/example/pubspec.yaml b/packages/firebase_ai/firebase_ai/example/pubspec.yaml new file mode 100644 index 000000000000..430a5c2df7e2 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/pubspec.yaml @@ -0,0 +1,47 @@ +name: firebase_ai_example +description: "Example project to show how to use the Firebase AI SDK." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +version: 1.0.0+1 + +environment: + sdk: '>=3.2.0 <4.0.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + + cupertino_icons: ^1.0.6 + firebase_ai: ^0.1.0 + firebase_core: ^3.13.0 + firebase_storage: ^12.4.5 + flutter: + sdk: flutter + flutter_markdown: ^0.6.20 + just_audio: ^0.9.43 + path_provider: ^2.1.5 + record: ^5.2.1 + +dev_dependencies: + flutter_lints: ^4.0.0 + flutter_test: + sdk: flutter + +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + assets: + - assets/images/ + - assets/documents/ + - assets/videos/ diff --git a/packages/firebase_ai/firebase_ai/example/web/favicon.png b/packages/firebase_ai/firebase_ai/example/web/favicon.png new file mode 100644 index 000000000000..8aaa46ac1ae2 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/web/favicon.png differ diff --git a/packages/firebase_ai/firebase_ai/example/web/icons/Icon-192.png b/packages/firebase_ai/firebase_ai/example/web/icons/Icon-192.png new file mode 100644 index 000000000000..b749bfef0747 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/web/icons/Icon-192.png differ diff --git a/packages/firebase_ai/firebase_ai/example/web/icons/Icon-512.png b/packages/firebase_ai/firebase_ai/example/web/icons/Icon-512.png new file mode 100644 index 000000000000..88cfd48dff11 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/web/icons/Icon-512.png differ diff --git a/packages/firebase_ai/firebase_ai/example/web/icons/Icon-maskable-192.png b/packages/firebase_ai/firebase_ai/example/web/icons/Icon-maskable-192.png new file mode 100644 index 000000000000..eb9b4d76e525 Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/web/icons/Icon-maskable-192.png differ diff --git a/packages/firebase_ai/firebase_ai/example/web/icons/Icon-maskable-512.png b/packages/firebase_ai/firebase_ai/example/web/icons/Icon-maskable-512.png new file mode 100644 index 000000000000..d69c56691fbd Binary files /dev/null and b/packages/firebase_ai/firebase_ai/example/web/icons/Icon-maskable-512.png differ diff --git a/packages/firebase_ai/firebase_ai/example/web/index.html b/packages/firebase_ai/firebase_ai/example/web/index.html new file mode 100644 index 000000000000..adc47a626031 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + flutterfire_vertexai + + + + + + diff --git a/packages/firebase_ai/firebase_ai/example/web/manifest.json b/packages/firebase_ai/firebase_ai/example/web/manifest.json new file mode 100644 index 000000000000..ffebd446235c --- /dev/null +++ b/packages/firebase_ai/firebase_ai/example/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "flutterfire_vertexai", + "short_name": "flutterfire_vertexai", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart new file mode 100644 index 000000000000..dbc95e1bca24 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/firebase_ai.dart @@ -0,0 +1,80 @@ +// Copyright 2024 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. + +export 'src/api.dart' + show + BlockReason, + Candidate, + CitationMetadata, + Citation, + CountTokensResponse, + FinishReason, + GenerateContentResponse, + GenerationConfig, + HarmBlockThreshold, + HarmCategory, + HarmProbability, + HarmBlockMethod, + PromptFeedback, + ResponseModalities, + SafetyRating, + SafetySetting, + UsageMetadata; +export 'src/base_model.dart' + show GenerativeModel, ImagenModel, LiveGenerativeModel; +export 'src/chat.dart' show ChatSession, StartChatExtension; +export 'src/content.dart' + show + Content, + InlineDataPart, + FileData, + FunctionCall, + FunctionResponse, + Part, + TextPart; +export 'src/error.dart' + show + FirebaseAIException, + FirebaseAISdkException, + InvalidApiKey, + ServerException, + UnsupportedUserLocation; +export 'src/firebase_ai.dart' show FirebaseAI; +export 'src/function_calling.dart' + show + FunctionCallingConfig, + FunctionCallingMode, + FunctionDeclaration, + Tool, + ToolConfig; +export 'src/imagen_api.dart' + show + ImagenSafetySettings, + ImagenFormat, + ImagenSafetyFilterLevel, + ImagenPersonFilterLevel, + ImagenGenerationConfig, + ImagenAspectRatio; +export 'src/imagen_content.dart' show ImagenInlineImage; +export 'src/live_api.dart' + show + LiveGenerationConfig, + SpeechConfig, + LiveServerMessage, + LiveServerContent, + LiveServerToolCall, + LiveServerToolCallCancellation, + LiveServerResponse; +export 'src/live_session.dart' show LiveSession; +export 'src/schema.dart' show Schema, SchemaType; diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/api.dart b/packages/firebase_ai/firebase_ai/lib/src/api.dart similarity index 86% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/api.dart rename to packages/firebase_ai/firebase_ai/lib/src/api.dart index 48e309268939..afe69f6dce52 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/api.dart @@ -14,6 +14,7 @@ import 'content.dart'; import 'error.dart'; +import 'function_calling.dart' show Tool, ToolConfig; import 'schema.dart'; /// Response for Count Tokens @@ -55,7 +56,7 @@ final class GenerateContentResponse { /// /// If the prompt was blocked, or the first candidate was finished for a reason /// of [FinishReason.recitation] or [FinishReason.safety], accessing this text - /// will throw a [VertexAIException]. + /// will throw a [FirebaseAIException]. /// /// If the first candidate's content contains any text parts, this value is /// the concatenation of the text. @@ -70,7 +71,7 @@ final class GenerateContentResponse { :final blockReasonMessage, ) => // TODO: Add a specific subtype for this exception? - throw VertexAIException('Response was blocked' + throw FirebaseAIException('Response was blocked' '${blockReason != null ? ' due to $blockReason' : ''}' '${blockReasonMessage != null ? ': $blockReasonMessage' : ''}'), _ => null, @@ -83,7 +84,7 @@ final class GenerateContentResponse { ), ... ] => - throw VertexAIException( + throw FirebaseAIException( // ignore: prefer_interpolation_to_compose_strings 'Candidate was blocked due to $finishReason' + (finishMessage != null && finishMessage.isNotEmpty @@ -164,6 +165,23 @@ final class UsageMetadata { final List? candidatesTokensDetails; } +/// Constructe a UsageMetadata with all it's fields. +/// +/// Expose access to the private constructor for use within the package.. +UsageMetadata createUsageMetadata({ + required int? promptTokenCount, + required int? candidatesTokenCount, + required int? totalTokenCount, + required List? promptTokensDetails, + required List? candidatesTokensDetails, +}) => + UsageMetadata._( + promptTokenCount: promptTokenCount, + candidatesTokenCount: candidatesTokenCount, + totalTokenCount: totalTokenCount, + promptTokensDetails: promptTokensDetails, + candidatesTokensDetails: candidatesTokensDetails); + /// Response candidate generated from a [GenerativeModel]. final class Candidate { // TODO: token count? @@ -212,7 +230,7 @@ final class Candidate { } else { suffix = ''; } - throw VertexAIException( + throw FirebaseAIException( 'Candidate was blocked due to $finishReason$suffix'); } return switch (content.parts) { @@ -883,53 +901,124 @@ enum TaskType { Object toJson() => _jsonString; } -/// Parse the json to [GenerateContentResponse] -GenerateContentResponse parseGenerateContentResponse(Object jsonObject) { - if (jsonObject case {'error': final Object error}) throw parseError(error); - final candidates = switch (jsonObject) { - {'candidates': final List candidates} => - candidates.map(_parseCandidate).toList(), - _ => [] - }; - final promptFeedback = switch (jsonObject) { - {'promptFeedback': final promptFeedback?} => - _parsePromptFeedback(promptFeedback), - _ => null, - }; - final usageMedata = switch (jsonObject) { - {'usageMetadata': final usageMetadata?} => - _parseUsageMetadata(usageMetadata), - _ => null, - }; - return GenerateContentResponse(candidates, promptFeedback, - usageMetadata: usageMedata); +// ignore: public_member_api_docs +abstract interface class SerializationStrategy { + // ignore: public_member_api_docs + GenerateContentResponse parseGenerateContentResponse(Object jsonObject); + // ignore: public_member_api_docs + CountTokensResponse parseCountTokensResponse(Object jsonObject); + // ignore: public_member_api_docs + Map generateContentRequest( + Iterable contents, + ({String prefix, String name}) model, + List safetySettings, + GenerationConfig? generationConfig, + List? tools, + ToolConfig? toolConfig, + Content? systemInstruction, + ); + + // ignore: public_member_api_docs + Map countTokensRequest( + Iterable contents, + ({String prefix, String name}) model, + List safetySettings, + GenerationConfig? generationConfig, + List? tools, + ToolConfig? toolConfig, + ); } -/// Parse the json to [CountTokensResponse] -CountTokensResponse parseCountTokensResponse(Object jsonObject) { - if (jsonObject case {'error': final Object error}) throw parseError(error); +// ignore: public_member_api_docs +final class VertexSerialization implements SerializationStrategy { + /// Parse the json to [GenerateContentResponse] + @override + GenerateContentResponse parseGenerateContentResponse(Object jsonObject) { + if (jsonObject case {'error': final Object error}) throw parseError(error); + final candidates = switch (jsonObject) { + {'candidates': final List candidates} => + candidates.map(_parseCandidate).toList(), + _ => [] + }; + final promptFeedback = switch (jsonObject) { + {'promptFeedback': final promptFeedback?} => + _parsePromptFeedback(promptFeedback), + _ => null, + }; + final usageMedata = switch (jsonObject) { + {'usageMetadata': final usageMetadata?} => + _parseUsageMetadata(usageMetadata), + {'totalTokens': final int totalTokens} => + UsageMetadata._(totalTokenCount: totalTokens), + _ => null, + }; + return GenerateContentResponse(candidates, promptFeedback, + usageMetadata: usageMedata); + } - if (jsonObject is! Map) { - throw unhandledFormat('CountTokensResponse', jsonObject); + /// Parse the json to [CountTokensResponse] + @override + CountTokensResponse parseCountTokensResponse(Object jsonObject) { + if (jsonObject case {'error': final Object error}) throw parseError(error); + + if (jsonObject is! Map) { + throw unhandledFormat('CountTokensResponse', jsonObject); + } + + final totalTokens = jsonObject['totalTokens'] as int; + final totalBillableCharacters = switch (jsonObject) { + {'totalBillableCharacters': final int totalBillableCharacters} => + totalBillableCharacters, + _ => null, + }; + final promptTokensDetails = switch (jsonObject) { + {'promptTokensDetails': final List promptTokensDetails} => + promptTokensDetails.map(_parseModalityTokenCount).toList(), + _ => null, + }; + + return CountTokensResponse( + totalTokens, + totalBillableCharacters: totalBillableCharacters, + promptTokensDetails: promptTokensDetails, + ); } - final totalTokens = jsonObject['totalTokens'] as int; - final totalBillableCharacters = switch (jsonObject) { - {'totalBillableCharacters': final int totalBillableCharacters} => - totalBillableCharacters, - _ => null, - }; - final promptTokensDetails = switch (jsonObject) { - {'promptTokensDetails': final List promptTokensDetails} => - promptTokensDetails.map(_parseModalityTokenCount).toList(), - _ => null, - }; + @override + Map generateContentRequest( + Iterable contents, + ({String prefix, String name}) model, + List safetySettings, + GenerationConfig? generationConfig, + List? tools, + ToolConfig? toolConfig, + Content? systemInstruction, + ) { + return { + 'model': '${model.prefix}/${model.name}', + 'contents': contents.map((c) => c.toJson()).toList(), + if (safetySettings.isNotEmpty) + 'safetySettings': safetySettings.map((s) => s.toJson()).toList(), + if (generationConfig != null) + 'generationConfig': generationConfig.toJson(), + if (tools != null) 'tools': tools.map((t) => t.toJson()).toList(), + if (toolConfig != null) 'toolConfig': toolConfig.toJson(), + if (systemInstruction != null) + 'systemInstruction': systemInstruction.toJson(), + }; + } - return CountTokensResponse( - totalTokens, - totalBillableCharacters: totalBillableCharacters, - promptTokensDetails: promptTokensDetails, - ); + @override + Map countTokensRequest( + Iterable contents, + ({String prefix, String name}) model, + List safetySettings, + GenerationConfig? generationConfig, + List? tools, + ToolConfig? toolConfig, + ) => + // Everything except contents is ignored. + {'contents': contents.map((c) => c.toJson()).toList()}; } Candidate _parseCandidate(Object? jsonObject) { diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/base_model.dart b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart similarity index 62% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/base_model.dart rename to packages/firebase_ai/firebase_ai/lib/src/base_model.dart index 4edcdb73da12..f63c883e7015 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/base_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/base_model.dart @@ -11,7 +11,6 @@ // 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. -library vertexai_model; import 'dart:async'; import 'dart:convert'; @@ -19,13 +18,16 @@ import 'dart:convert'; 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:http/http.dart' as http; import 'package:meta/meta.dart'; import 'package:web_socket_channel/io.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; import 'api.dart'; import 'client.dart'; import 'content.dart'; +import 'developer/api.dart'; import 'function_calling.dart'; import 'imagen_api.dart'; import 'imagen_content.dart'; @@ -52,33 +54,28 @@ enum Task { predict, } -/// Base class for models. -/// -/// Do not instantiate directly. -abstract class BaseModel { - // ignore: public_member_api_docs - BaseModel( +abstract interface class _ModelUri { + String get baseAuthority; + Uri taskUri(Task task); + ({String prefix, String name}) get model; +} + +final class _VertexUri implements _ModelUri { + _VertexUri( {required String model, required String location, required FirebaseApp app}) - : _model = normalizeModelName(model), + : model = _normalizeModelName(model), _projectUri = _vertexUri(app, location); - static const _baseUrl = 'firebasevertexai.googleapis.com'; + static const _baseAuthority = 'firebasevertexai.googleapis.com'; static const _apiVersion = 'v1beta'; - final ({String prefix, String name}) _model; - - final Uri _projectUri; - - /// The normalized model name. - ({String prefix, String name}) get model => _model; - /// Returns the model code for a user friendly model name. /// /// If the model name is already a model code (contains a `/`), use the parts /// directly. Otherwise, return a `models/` model code. - static ({String prefix, String name}) normalizeModelName(String modelName) { + static ({String prefix, String name}) _normalizeModelName(String modelName) { if (!modelName.contains('/')) return (prefix: 'models', name: modelName); final parts = modelName.split('/'); return (prefix: parts.first, name: parts.skip(1).join('/')); @@ -87,11 +84,79 @@ abstract class BaseModel { static Uri _vertexUri(FirebaseApp app, String location) { var projectId = app.options.projectId; return Uri.https( - _baseUrl, + _baseAuthority, '/$_apiVersion/projects/$projectId/locations/$location/publishers/google', ); } + final Uri _projectUri; + @override + final ({String prefix, String name}) model; + + @override + String get baseAuthority => _baseAuthority; + + @override + Uri taskUri(Task task) { + return _projectUri.replace( + pathSegments: _projectUri.pathSegments + .followedBy([model.prefix, '${model.name}:${task.name}'])); + } +} + +final class _GoogleAIUri implements _ModelUri { + _GoogleAIUri({ + required String model, + required FirebaseApp app, + }) : model = _normalizeModelName(model), + _baseUri = _googleAIBaseUri(app: app); + + /// Returns the model code for a user friendly model name. + /// + /// If the model name is already a model code (contains a `/`), use the parts + /// directly. Otherwise, return a `models/` model code. + static ({String prefix, String name}) _normalizeModelName(String modelName) { + if (!modelName.contains('/')) return (prefix: 'models', name: modelName); + final parts = modelName.split('/'); + return (prefix: parts.first, name: parts.skip(1).join('/')); + } + + static const _apiVersion = 'v1beta'; + static const _baseAuthority = 'firebasevertexai.googleapis.com'; + static Uri _googleAIBaseUri( + {String apiVersion = _apiVersion, required FirebaseApp app}) => + Uri.https( + _baseAuthority, '$apiVersion/projects/${app.options.projectId}'); + final Uri _baseUri; + + @override + final ({String prefix, String name}) model; + + @override + String get baseAuthority => _baseAuthority; + + @override + Uri taskUri(Task task) => _baseUri.replace( + pathSegments: _baseUri.pathSegments + .followedBy([model.prefix, '${model.name}:${task.name}'])); +} + +/// Base class for models. +/// +/// Do not instantiate directly. +abstract class BaseModel { + BaseModel._( + {required SerializationStrategy serializationStrategy, + required _ModelUri modelUri}) + : _serializationStrategy = serializationStrategy, + _modelUri = modelUri; + + final SerializationStrategy _serializationStrategy; + final _ModelUri _modelUri; + + /// The normalized model name. + ({String prefix, String name}) get model => _modelUri.model; + /// Returns a function that generates Firebase auth tokens. static FutureOr> Function() firebaseTokens( FirebaseAppCheck? appCheck, FirebaseAuth? auth, FirebaseApp? app) { @@ -120,9 +185,7 @@ abstract class BaseModel { } /// Returns a URI for the given [task]. - Uri taskUri(Task task) => _projectUri.replace( - pathSegments: _projectUri.pathSegments - .followedBy([_model.prefix, '${_model.name}:${task.name}'])); + Uri taskUri(Task task) => _modelUri.taskUri(task); } /// An abstract base class for models that interact with an API using an [ApiClient]. @@ -136,11 +199,11 @@ abstract class BaseModel { abstract class BaseApiClientModel extends BaseModel { // ignore: public_member_api_docs BaseApiClientModel({ - required super.model, - required super.location, - required super.app, + required super.serializationStrategy, + required super.modelUri, required ApiClient client, - }) : _client = client; + }) : _client = client, + super._(); final ApiClient _client; diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/chat.dart b/packages/firebase_ai/firebase_ai/lib/src/chat.dart similarity index 100% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/chat.dart rename to packages/firebase_ai/firebase_ai/lib/src/chat.dart diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/client.dart b/packages/firebase_ai/firebase_ai/lib/src/client.dart similarity index 99% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/client.dart rename to packages/firebase_ai/firebase_ai/lib/src/client.dart index 8a5912c3e1ae..ba3eed67b6fe 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/client.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/client.dart @@ -69,7 +69,7 @@ final class HttpApiClient implements ApiClient { body: _utf8Json.encode(body), ); if (response.statusCode >= 500) { - throw VertexAIException( + throw FirebaseAIException( 'Server Error [${response.statusCode}]: ${response.body}'); } diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/content.dart b/packages/firebase_ai/firebase_ai/lib/src/content.dart similarity index 100% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/content.dart rename to packages/firebase_ai/firebase_ai/lib/src/content.dart diff --git a/packages/firebase_ai/firebase_ai/lib/src/developer/api.dart b/packages/firebase_ai/firebase_ai/lib/src/developer/api.dart new file mode 100644 index 000000000000..e1a56f30a894 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/src/developer/api.dart @@ -0,0 +1,324 @@ +// Copyright 2025 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 '../api.dart' + show + BlockReason, + Candidate, + Citation, + CitationMetadata, + CountTokensResponse, + FinishReason, + GenerateContentResponse, + GenerationConfig, + HarmBlockThreshold, + HarmCategory, + HarmProbability, + PromptFeedback, + SafetyRating, + SafetySetting, + SerializationStrategy, + UsageMetadata, + createUsageMetadata; +import '../content.dart' show Content, FunctionCall, Part, TextPart; +import '../error.dart'; +import '../function_calling.dart' show Tool, ToolConfig; + +HarmProbability _parseHarmProbability(Object jsonObject) => + switch (jsonObject) { + 'UNSPECIFIED' => HarmProbability.unknown, + 'NEGLIGIBLE' => HarmProbability.negligible, + 'LOW' => HarmProbability.low, + 'MEDIUM' => HarmProbability.medium, + 'HIGH' => HarmProbability.high, + _ => throw unhandledFormat('HarmProbability', jsonObject), + }; +HarmCategory _parseHarmCategory(Object jsonObject) => switch (jsonObject) { + 'HARM_CATEGORY_UNSPECIFIED' => HarmCategory.unknown, + 'HARM_CATEGORY_HARASSMENT' => HarmCategory.harassment, + 'HARM_CATEGORY_HATE_SPEECH' => HarmCategory.hateSpeech, + 'HARM_CATEGORY_SEXUALLY_EXPLICIT' => HarmCategory.sexuallyExplicit, + 'HARM_CATEGORY_DANGEROUS_CONTENT' => HarmCategory.dangerousContent, + _ => throw unhandledFormat('HarmCategory', jsonObject), + }; + +FinishReason _parseFinishReason(Object jsonObject) => switch (jsonObject) { + 'UNSPECIFIED' => FinishReason.unknown, + 'STOP' => FinishReason.stop, + 'MAX_TOKENS' => FinishReason.maxTokens, + 'SAFETY' => FinishReason.safety, + 'RECITATION' => FinishReason.recitation, + 'OTHER' => FinishReason.other, + _ => throw unhandledFormat('FinishReason', jsonObject), + }; +BlockReason _parseBlockReason(String jsonObject) => switch (jsonObject) { + 'BLOCK_REASON_UNSPECIFIED' => BlockReason.unknown, + 'SAFETY' => BlockReason.safety, + 'OTHER' => BlockReason.other, + _ => throw unhandledFormat('BlockReason', jsonObject), + }; +String _harmBlockThresholdtoJson(HarmBlockThreshold? threshold) => + switch (threshold) { + null => 'HARM_BLOCK_THRESHOLD_UNSPECIFIED', + HarmBlockThreshold.low => 'BLOCK_LOW_AND_ABOVE', + HarmBlockThreshold.medium => 'BLOCK_MEDIUM_AND_ABOVE', + HarmBlockThreshold.high => 'BLOCK_ONLY_HIGH', + HarmBlockThreshold.none => 'BLOCK_NONE', + HarmBlockThreshold.off => 'OFF', + }; +String _harmCategoryToJson(HarmCategory harmCategory) => switch (harmCategory) { + HarmCategory.unknown => 'HARM_CATEGORY_UNSPECIFIED', + HarmCategory.harassment => 'HARM_CATEGORY_HARASSMENT', + HarmCategory.hateSpeech => 'HARM_CATEGORY_HATE_SPEECH', + HarmCategory.sexuallyExplicit => 'HARM_CATEGORY_SEXUALLY_EXPLICIT', + HarmCategory.dangerousContent => 'HARM_CATEGORY_DANGEROUS_CONTENT' + }; + +Object _safetySettingToJson(SafetySetting safetySetting) { + if (safetySetting.method != null) { + throw ArgumentError( + 'HarmBlockMethod is not supported by google AI and must be left null.'); + } + return { + 'category': _harmCategoryToJson(safetySetting.category), + 'threshold': _harmBlockThresholdtoJson(safetySetting.threshold) + }; +} + +// ignore: public_member_api_docs +final class DeveloperSerialization implements SerializationStrategy { + @override + GenerateContentResponse parseGenerateContentResponse(Object jsonObject) { + if (jsonObject case {'error': final Object error}) throw parseError(error); + final candidates = switch (jsonObject) { + {'candidates': final List candidates} => + candidates.map(_parseCandidate).toList(), + _ => [] + }; + final promptFeedback = switch (jsonObject) { + {'promptFeedback': final promptFeedback?} => + _parsePromptFeedback(promptFeedback), + _ => null, + }; + final usageMedata = switch (jsonObject) { + {'usageMetadata': final usageMetadata?} => + _parseUsageMetadata(usageMetadata), + _ => null, + }; + return GenerateContentResponse(candidates, promptFeedback, + usageMetadata: usageMedata); + } + + @override + CountTokensResponse parseCountTokensResponse(Object jsonObject) { + if (jsonObject case {'error': final Object error}) throw parseError(error); + if (jsonObject case {'totalTokens': final int totalTokens}) { + return CountTokensResponse(totalTokens); + } + throw unhandledFormat('CountTokensResponse', jsonObject); + } + + @override + Map generateContentRequest( + Iterable contents, + ({String prefix, String name}) model, + List safetySettings, + GenerationConfig? generationConfig, + List? tools, + ToolConfig? toolConfig, + Content? systemInstruction, + ) { + return { + 'model': '${model.prefix}/${model.name}', + 'contents': contents.map((c) => c.toJson()).toList(), + if (safetySettings.isNotEmpty) + 'safetySettings': safetySettings.map(_safetySettingToJson).toList(), + if (generationConfig != null) + 'generationConfig': generationConfig.toJson(), + if (tools != null) 'tools': tools.map((t) => t.toJson()).toList(), + if (toolConfig != null) 'toolConfig': toolConfig.toJson(), + if (systemInstruction != null) + 'systemInstruction': systemInstruction.toJson(), + }; + } + + @override + Map countTokensRequest( + Iterable contents, + ({String prefix, String name}) model, + List safetySettings, + GenerationConfig? generationConfig, + List? tools, + ToolConfig? toolConfig, + ) => + { + 'generateContentRequest': generateContentRequest( + contents, + model, + safetySettings, + generationConfig, + tools, + toolConfig, + null, + ) + }; +} + +Candidate _parseCandidate(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('Candidate', jsonObject); + } + + return Candidate( + jsonObject.containsKey('content') + ? _parseGoogleAIContent(jsonObject['content'] as Object) + : Content(null, []), + switch (jsonObject) { + {'safetyRatings': final List safetyRatings} => + safetyRatings.map(_parseSafetyRating).toList(), + _ => null + }, + switch (jsonObject) { + {'citationMetadata': final Object citationMetadata} => + _parseCitationMetadata(citationMetadata), + _ => null + }, + switch (jsonObject) { + {'finishReason': final Object finishReason} => + _parseFinishReason(finishReason), + _ => null + }, + switch (jsonObject) { + {'finishMessage': final String finishMessage} => finishMessage, + _ => null + }, + ); +} + +PromptFeedback _parsePromptFeedback(Object jsonObject) { + return switch (jsonObject) { + { + 'safetyRatings': final List safetyRatings, + } => + PromptFeedback( + switch (jsonObject) { + {'blockReason': final String blockReason} => + _parseBlockReason(blockReason), + _ => null, + }, + switch (jsonObject) { + {'blockReasonMessage': final String blockReasonMessage} => + blockReasonMessage, + _ => null, + }, + safetyRatings.map(_parseSafetyRating).toList()), + _ => throw unhandledFormat('PromptFeedback', jsonObject), + }; +} + +UsageMetadata _parseUsageMetadata(Object jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('UsageMetadata', jsonObject); + } + final promptTokenCount = switch (jsonObject) { + {'promptTokenCount': final int promptTokenCount} => promptTokenCount, + _ => null, + }; + final candidatesTokenCount = switch (jsonObject) { + {'candidatesTokenCount': final int candidatesTokenCount} => + candidatesTokenCount, + _ => null, + }; + final totalTokenCount = switch (jsonObject) { + {'totalTokenCount': final int totalTokenCount} => totalTokenCount, + _ => null, + }; + return createUsageMetadata( + promptTokenCount: promptTokenCount, + candidatesTokenCount: candidatesTokenCount, + totalTokenCount: totalTokenCount, + promptTokensDetails: null, + candidatesTokensDetails: null, + ); +} + +SafetyRating _parseSafetyRating(Object? jsonObject) { + return switch (jsonObject) { + { + 'category': final Object category, + 'probability': final Object probability + } => + SafetyRating( + _parseHarmCategory(category), _parseHarmProbability(probability)), + _ => throw unhandledFormat('SafetyRating', jsonObject), + }; +} + +CitationMetadata _parseCitationMetadata(Object? jsonObject) { + return switch (jsonObject) { + {'citationSources': final List citationSources} => + CitationMetadata(citationSources.map(_parseCitationSource).toList()), + // Vertex SDK format uses `citations` + {'citations': final List citationSources} => + CitationMetadata(citationSources.map(_parseCitationSource).toList()), + _ => throw unhandledFormat('CitationMetadata', jsonObject), + }; +} + +Citation _parseCitationSource(Object? jsonObject) { + if (jsonObject is! Map) { + throw unhandledFormat('CitationSource', jsonObject); + } + + final uriString = jsonObject['uri'] as String?; + + return Citation( + jsonObject['startIndex'] as int?, + jsonObject['endIndex'] as int?, + uriString != null ? Uri.parse(uriString) : null, + jsonObject['license'] as String?, + ); +} + +Content _parseGoogleAIContent(Object jsonObject) { + return switch (jsonObject) { + {'parts': final List parts} => Content( + switch (jsonObject) { + {'role': final String role} => role, + _ => null, + }, + parts.map(_parsePart).toList()), + _ => throw unhandledFormat('Content', jsonObject), + }; +} + +Part _parsePart(Object? jsonObject) { + return switch (jsonObject) { + {'text': final String text} => TextPart(text), + { + 'functionCall': { + 'name': final String name, + 'args': final Map args + } + } => + FunctionCall(name, args), + { + 'functionResponse': {'name': String _, 'response': Map _} + } => + throw UnimplementedError('FunctionResponse part not yet supported'), + {'inlineData': {'mimeType': String _, 'data': String _}} => + throw UnimplementedError('inlineData content part not yet supported'), + _ => throw unhandledFormat('Part', jsonObject), + }; +} diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/error.dart b/packages/firebase_ai/firebase_ai/lib/src/error.dart similarity index 87% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/error.dart rename to packages/firebase_ai/firebase_ai/lib/src/error.dart index 375b241c54bf..498d2e2a417e 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/error.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/error.dart @@ -15,9 +15,9 @@ /// Exception thrown when generating content fails. /// /// The [message] may explain the cause of the failure. -final class VertexAIException implements Exception { +final class FirebaseAIException implements Exception { // ignore: public_member_api_docs - VertexAIException(this.message); + FirebaseAIException(this.message); /// Message of the exception final String message; @@ -27,7 +27,7 @@ final class VertexAIException implements Exception { } /// Exception thrown when the server rejects the API key. -final class InvalidApiKey implements VertexAIException { +final class InvalidApiKey implements FirebaseAIException { // ignore: public_member_api_docs InvalidApiKey(this.message); @override @@ -38,14 +38,14 @@ final class InvalidApiKey implements VertexAIException { } /// Exception thrown when the user location is unsupported. -final class UnsupportedUserLocation implements VertexAIException { +final class UnsupportedUserLocation implements FirebaseAIException { static const _message = 'User location is not supported for the API use.'; @override String get message => _message; } /// Exception thrown when the service API is not enabled. -final class ServiceApiNotEnabled implements VertexAIException { +final class ServiceApiNotEnabled implements FirebaseAIException { // ignore: public_member_api_docs ServiceApiNotEnabled(this._projectId); @@ -69,7 +69,7 @@ final class ServiceApiNotEnabled implements VertexAIException { } /// Exception thrown when the quota is exceeded. -final class QuotaExceeded implements VertexAIException { +final class QuotaExceeded implements FirebaseAIException { // ignore: public_member_api_docs QuotaExceeded(this.message); @override @@ -80,7 +80,7 @@ final class QuotaExceeded implements VertexAIException { } /// Exception thrown when the server failed to generate content. -final class ServerException implements VertexAIException { +final class ServerException implements FirebaseAIException { // ignore: public_member_api_docs ServerException(this.message); @override @@ -95,9 +95,9 @@ final class ServerException implements VertexAIException { /// This exception indicates a likely problem with the SDK implementation such /// as an inability to parse a new response format. Resolution paths may include /// updating to a new version of the SDK, or filing an issue. -final class VertexAISdkException implements Exception { +final class FirebaseAISdkException implements Exception { // ignore: public_member_api_docs - VertexAISdkException(this.message); + FirebaseAISdkException(this.message); /// Message of the exception final String message; @@ -106,7 +106,7 @@ final class VertexAISdkException implements Exception { String toString() => '$message\n' 'This indicates a problem with the Vertex AI in Firebase SDK. ' 'Try updating to the latest version ' - '(https://pub.dev/packages/firebase_vertexai/versions), ' + '(https://pub.dev/packages/firebase_ai/versions), ' 'or file an issue at ' 'https://github.com/firebase/flutterfire/issues.'; } @@ -150,7 +150,7 @@ final class LiveWebSocketClosedException implements Exception { } /// Parse the error json object. -VertexAIException parseError(Object jsonObject) { +FirebaseAIException parseError(Object jsonObject) { return switch (jsonObject) { { 'message': final String message, @@ -180,6 +180,6 @@ VertexAIException parseError(Object jsonObject) { }; } -/// Throw [VertexAISdkException] for unhandled format. +/// Throw [FirebaseAISdkException] for unhandled format. Exception unhandledFormat(String name, Object? jsonObject) => - VertexAISdkException('Unhandled format for $name: $jsonObject'); + FirebaseAISdkException('Unhandled format for $name: $jsonObject'); diff --git a/packages/firebase_ai/firebase_ai/lib/src/firebase_ai.dart b/packages/firebase_ai/firebase_ai/lib/src/firebase_ai.dart new file mode 100644 index 000000000000..acbcb3c9a069 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/lib/src/firebase_ai.dart @@ -0,0 +1,189 @@ +// Copyright 2024 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_app_check/firebase_app_check.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_core_platform_interface/firebase_core_platform_interface.dart' + show FirebasePluginPlatform; +import 'package:meta/meta.dart'; + +import '../firebase_ai.dart'; +import 'base_model.dart'; + +const _defaultLocation = 'us-central1'; + +/// The entrypoint for generative models. +class FirebaseAI extends FirebasePluginPlatform { + FirebaseAI._( + {required this.app, + required this.location, + required bool useVertexBackend, + this.appCheck, + this.auth}) + : _useVertexBackend = useVertexBackend, + super(app.name, 'plugins.flutter.io/firebase_vertexai'); + + /// The [FirebaseApp] for this current [FirebaseAI] instance. + FirebaseApp app; + + /// The optional [FirebaseAppCheck] for this current [FirebaseAI] instance. + /// https://firebase.google.com/docs/app-check + FirebaseAppCheck? appCheck; + + /// The optional [FirebaseAuth] for this current [FirebaseAI] instance. + FirebaseAuth? auth; + + /// The service location for this [FirebaseAI] instance. + String location; + + final bool _useVertexBackend; + + static final Map _cachedInstances = {}; + + /// Returns an instance using a specified [FirebaseApp]. + /// + /// If [app] is not provided, the default Firebase app will be used. + /// If pass in [appCheck], request session will get protected from abusing. + static FirebaseAI vertexAI({ + FirebaseApp? app, + FirebaseAppCheck? appCheck, + FirebaseAuth? auth, + String? location, + }) { + app ??= Firebase.app(); + var instanceKey = '${app.name}::vertexai'; + + if (_cachedInstances.containsKey(instanceKey)) { + return _cachedInstances[instanceKey]!; + } + + location ??= _defaultLocation; + + FirebaseAI newInstance = FirebaseAI._( + app: app, + location: location, + appCheck: appCheck, + auth: auth, + useVertexBackend: true, + ); + _cachedInstances[instanceKey] = newInstance; + + return newInstance; + } + + /// Returns an instance using a specified [FirebaseApp]. + /// + /// If [app] is not provided, the default Firebase app will be used. + /// If pass in [appCheck], request session will get protected from abusing. + static FirebaseAI googleAI({ + FirebaseApp? app, + FirebaseAppCheck? appCheck, + FirebaseAuth? auth, + }) { + app ??= Firebase.app(); + var instanceKey = '${app.name}::googleai'; + + if (_cachedInstances.containsKey(instanceKey)) { + return _cachedInstances[instanceKey]!; + } + + FirebaseAI newInstance = FirebaseAI._( + app: app, + location: _defaultLocation, + appCheck: appCheck, + auth: auth, + useVertexBackend: false, + ); + _cachedInstances[instanceKey] = newInstance; + + return newInstance; + } + + /// Create a [GenerativeModel] backed by the generative model named [model]. + /// + /// The [model] argument can be a model name (such as `'gemini-pro'`) or a + /// model code (such as `'models/gemini-pro'`). + /// There is no creation time check for whether the `model` string identifies + /// a known and supported model. If not, attempts to generate content + /// will fail. + /// + /// The optional [safetySettings] and [generationConfig] can be used to + /// control and guide the generation. See [SafetySetting] and + /// [GenerationConfig] for details. + GenerativeModel generativeModel({ + required String model, + List? safetySettings, + GenerationConfig? generationConfig, + List? tools, + ToolConfig? toolConfig, + Content? systemInstruction, + }) { + return createGenerativeModel( + model: model, + app: app, + appCheck: appCheck, + useVertexBackend: _useVertexBackend, + auth: auth, + location: location, + safetySettings: safetySettings, + generationConfig: generationConfig, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + ); + } + + /// Create a [ImagenModel]. + /// + /// The optional [safetySettings] can be used to control and guide the + /// generation. See [ImagenSafetySettings] for details. + @experimental + ImagenModel imagenModel( + {required String model, + ImagenGenerationConfig? generationConfig, + ImagenSafetySettings? safetySettings}) { + return createImagenModel( + app: app, + location: location, + model: model, + useVertexBackend: _useVertexBackend, + generationConfig: generationConfig, + safetySettings: safetySettings, + appCheck: appCheck, + auth: auth); + } + + /// Create a [LiveGenerativeModel] for real-time interaction. + /// + /// The optional [liveGenerationConfig] can be used to control and guide the + /// generation. See [LiveGenerationConfig] for details. + LiveGenerativeModel liveGenerativeModel({ + required String model, + LiveGenerationConfig? liveGenerationConfig, + List? tools, + Content? systemInstruction, + }) { + return createLiveGenerativeModel( + app: app, + location: location, + model: model, + liveGenerationConfig: liveGenerationConfig, + tools: tools, + systemInstruction: systemInstruction, + appCheck: appCheck, + auth: auth, + ); + } +} diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/function_calling.dart b/packages/firebase_ai/firebase_ai/lib/src/function_calling.dart similarity index 100% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/function_calling.dart rename to packages/firebase_ai/firebase_ai/lib/src/function_calling.dart diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/generative_model.dart b/packages/firebase_ai/firebase_ai/lib/src/generative_model.dart similarity index 78% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/generative_model.dart rename to packages/firebase_ai/firebase_ai/lib/src/generative_model.dart index 31841f8834c1..2f61bd3e4c2d 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/generative_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/generative_model.dart @@ -13,11 +13,11 @@ // limitations under the License. // ignore_for_file: use_late_for_private_fields_and_variables -part of vertexai_model; +part of 'base_model.dart'; /// A multimodel generative model (like Gemini). /// -/// Allows generating content, creating embeddings, and counting the number of +/// Allows generating content and counting the number of /// tokens in a piece of content. final class GenerativeModel extends BaseApiClientModel { /// Create a [GenerativeModel] backed by the generative model named [model]. @@ -36,6 +36,7 @@ final class GenerativeModel extends BaseApiClientModel { required String model, required String location, required FirebaseApp app, + required bool useVertexBackend, FirebaseAppCheck? appCheck, FirebaseAuth? auth, List? safetySettings, @@ -50,9 +51,12 @@ final class GenerativeModel extends BaseApiClientModel { _toolConfig = toolConfig, _systemInstruction = systemInstruction, super( - model: model, - app: app, - location: location, + serializationStrategy: useVertexBackend + ? VertexSerialization() + : DeveloperSerialization(), + modelUri: useVertexBackend + ? _VertexUri(app: app, model: model, location: location) + : _GoogleAIUri(app: app, model: model), client: HttpApiClient( apiKey: app.options.apiKey, httpClient: httpClient, @@ -62,6 +66,7 @@ final class GenerativeModel extends BaseApiClientModel { required String model, required String location, required FirebaseApp app, + required useVertexBackend, FirebaseAppCheck? appCheck, FirebaseAuth? auth, List? safetySettings, @@ -76,9 +81,12 @@ final class GenerativeModel extends BaseApiClientModel { _toolConfig = toolConfig, _systemInstruction = systemInstruction, super( - model: model, - app: app, - location: location, + serializationStrategy: useVertexBackend + ? VertexSerialization() + : DeveloperSerialization(), + modelUri: useVertexBackend + ? _VertexUri(app: app, model: model, location: location) + : _GoogleAIUri(app: app, model: model), client: apiClient ?? HttpApiClient( apiKey: app.options.apiKey, @@ -92,31 +100,6 @@ final class GenerativeModel extends BaseApiClientModel { final ToolConfig? _toolConfig; final Content? _systemInstruction; - Map _generateContentRequest( - Iterable contents, { - List? safetySettings, - GenerationConfig? generationConfig, - List? tools, - ToolConfig? toolConfig, - }) { - safetySettings ??= _safetySettings; - generationConfig ??= _generationConfig; - tools ??= _tools; - toolConfig ??= _toolConfig; - return { - 'model': '${model.prefix}/${model.name}', - 'contents': contents.map((c) => c.toJson()).toList(), - if (safetySettings.isNotEmpty) - 'safetySettings': safetySettings.map((s) => s.toJson()).toList(), - if (generationConfig != null) - 'generationConfig': generationConfig.toJson(), - if (tools != null) 'tools': tools.map((t) => t.toJson()).toList(), - if (toolConfig != null) 'toolConfig': toolConfig.toJson(), - if (_systemInstruction case final systemInstruction?) - 'systemInstruction': systemInstruction.toJson(), - }; - } - /// Generates content responding to [prompt]. /// /// Sends a "generateContent" API request for the configured model, @@ -134,14 +117,16 @@ final class GenerativeModel extends BaseApiClientModel { ToolConfig? toolConfig}) => makeRequest( Task.generateContent, - _generateContentRequest( + _serializationStrategy.generateContentRequest( prompt, - safetySettings: safetySettings, - generationConfig: generationConfig, - tools: tools, - toolConfig: toolConfig, + model, + safetySettings ?? _safetySettings, + generationConfig ?? _generationConfig, + tools ?? _tools, + toolConfig ?? _toolConfig, + _systemInstruction, ), - parseGenerateContentResponse); + _serializationStrategy.parseGenerateContentResponse); /// Generates a stream of content responding to [prompt]. /// @@ -163,14 +148,16 @@ final class GenerativeModel extends BaseApiClientModel { ToolConfig? toolConfig}) { final response = client.streamRequest( taskUri(Task.streamGenerateContent), - _generateContentRequest( + _serializationStrategy.generateContentRequest( prompt, - safetySettings: safetySettings, - generationConfig: generationConfig, - tools: tools, - toolConfig: toolConfig, + model, + safetySettings ?? _safetySettings, + generationConfig ?? _generationConfig, + tools ?? _tools, + toolConfig ?? _toolConfig, + _systemInstruction, )); - return response.map(parseGenerateContentResponse); + return response.map(_serializationStrategy.parseGenerateContentResponse); } /// Counts the total number of tokens in [contents]. @@ -193,10 +180,16 @@ final class GenerativeModel extends BaseApiClientModel { Future countTokens( Iterable contents, ) async { - final parameters = { - 'contents': contents.map((c) => c.toJson()).toList() - }; - return makeRequest(Task.countTokens, parameters, parseCountTokensResponse); + final parameters = _serializationStrategy.countTokensRequest( + contents, + model, + _safetySettings, + _generationConfig, + _tools, + _toolConfig, + ); + return makeRequest(Task.countTokens, parameters, + _serializationStrategy.parseCountTokensResponse); } } @@ -205,6 +198,7 @@ GenerativeModel createGenerativeModel({ required FirebaseApp app, required String location, required String model, + required bool useVertexBackend, FirebaseAppCheck? appCheck, FirebaseAuth? auth, GenerationConfig? generationConfig, @@ -217,6 +211,7 @@ GenerativeModel createGenerativeModel({ model: model, app: app, appCheck: appCheck, + useVertexBackend: useVertexBackend, auth: auth, location: location, safetySettings: safetySettings, @@ -234,6 +229,7 @@ GenerativeModel createModelWithClient({ required String location, required String model, required ApiClient client, + required bool useVertexBackend, Content? systemInstruction, FirebaseAppCheck? appCheck, FirebaseAuth? auth, @@ -246,6 +242,7 @@ GenerativeModel createModelWithClient({ model: model, app: app, appCheck: appCheck, + useVertexBackend: useVertexBackend, auth: auth, location: location, safetySettings: safetySettings, diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/imagen_api.dart b/packages/firebase_ai/firebase_ai/lib/src/imagen_api.dart similarity index 100% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/imagen_api.dart rename to packages/firebase_ai/firebase_ai/lib/src/imagen_api.dart diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/imagen_content.dart b/packages/firebase_ai/firebase_ai/lib/src/imagen_content.dart similarity index 100% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/imagen_content.dart rename to packages/firebase_ai/firebase_ai/lib/src/imagen_content.dart diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/imagen_model.dart b/packages/firebase_ai/firebase_ai/lib/src/imagen_model.dart similarity index 92% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/imagen_model.dart rename to packages/firebase_ai/firebase_ai/lib/src/imagen_model.dart index 68adf1f67e7e..bf4731a3b264 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/imagen_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/imagen_model.dart @@ -11,7 +11,8 @@ // 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. -part of vertexai_model; + +part of 'base_model.dart'; /// Represents a remote Imagen model with the ability to generate images using /// text prompts. @@ -29,6 +30,7 @@ final class ImagenModel extends BaseApiClientModel { {required FirebaseApp app, required String model, required String location, + required bool useVertexBackend, FirebaseAppCheck? appCheck, FirebaseAuth? auth, ImagenGenerationConfig? generationConfig, @@ -36,9 +38,10 @@ final class ImagenModel extends BaseApiClientModel { : _generationConfig = generationConfig, _safetySettings = safetySettings, super( - model: model, - app: app, - location: location, + serializationStrategy: VertexSerialization(), + modelUri: useVertexBackend + ? _VertexUri(app: app, model: model, location: location) + : _GoogleAIUri(app: app, model: model), client: HttpApiClient( apiKey: app.options.apiKey, requestHeaders: BaseModel.firebaseTokens(appCheck, auth, app))); @@ -115,6 +118,7 @@ ImagenModel createImagenModel({ required FirebaseApp app, required String location, required String model, + required bool useVertexBackend, FirebaseAppCheck? appCheck, FirebaseAuth? auth, ImagenGenerationConfig? generationConfig, @@ -126,6 +130,7 @@ ImagenModel createImagenModel({ appCheck: appCheck, auth: auth, location: location, + useVertexBackend: useVertexBackend, safetySettings: safetySettings, generationConfig: generationConfig, ); diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/live_api.dart b/packages/firebase_ai/firebase_ai/lib/src/live_api.dart similarity index 97% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/live_api.dart rename to packages/firebase_ai/firebase_ai/lib/src/live_api.dart index 075c69eeef41..8ae67a65051f 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/live_api.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/live_api.dart @@ -241,14 +241,14 @@ class LiveClientToolResponse { /// Parses a JSON object received from the live server into a [LiveServerResponse]. /// /// This function handles different types of server messages, including: -/// - Error messages, which result in a [VertexAIException] being thrown. +/// - Error messages, which result in a [FirebaseAIException] being thrown. /// - `serverContent` messages containing model-generated content. /// - `toolCall` messages indicating function calls requested by the model. /// - `toolCallCancellation` messages to cancel pending function calls. /// - `setupComplete` messages signaling the completion of the server setup. /// /// If the JSON object does not match any of the expected formats, an -/// [VertexAISdkException] is thrown. +/// [FirebaseAISdkException] is thrown. /// /// Example: /// ```dart @@ -269,8 +269,8 @@ class LiveClientToolResponse { /// ``` /// /// Throws: -/// - [VertexAIException]: If the JSON object contains an error message. -/// - [VertexAISdkException]: If the JSON object does not match any expected format. +/// - [FirebaseAIException]: If the JSON object contains an error message. +/// - [FirebaseAISdkException]: If the JSON object does not match any expected format. /// /// Parameters: /// - [jsonObject]: The JSON object received from the live server. diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/live_model.dart b/packages/firebase_ai/firebase_ai/lib/src/live_model.dart similarity index 83% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/live_model.dart rename to packages/firebase_ai/firebase_ai/lib/src/live_model.dart index 57d844d566ae..4787d0ea97ed 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/live_model.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/live_model.dart @@ -11,7 +11,8 @@ // 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. -part of vertexai_model; + +part of 'base_model.dart'; const _apiUrl = 'ws/google.firebase.vertexai'; const _apiUrlSuffix = 'LlmBidiService/BidiGenerateContent/locations'; @@ -43,11 +44,15 @@ final class LiveGenerativeModel extends BaseModel { _liveGenerationConfig = liveGenerationConfig, _tools = tools, _systemInstruction = systemInstruction, - super( - model: model, - app: app, - location: location, + super._( + serializationStrategy: VertexSerialization(), + modelUri: _VertexUri( + model: model, + app: app, + location: location, + ), ); + static const _apiVersion = 'v1beta'; final FirebaseApp _app; final String _location; @@ -65,10 +70,11 @@ final class LiveGenerativeModel extends BaseModel { /// Returns a [Future] that resolves to an [LiveSession] object upon successful /// connection. Future connect() async { - final uri = - 'wss://${BaseModel._baseUrl}/$_apiUrl.${BaseModel._apiVersion}.$_apiUrlSuffix/$_location?key=${_app.options.apiKey}'; - final modelString = - 'projects/${_app.options.projectId}/locations/$_location/publishers/google/models/${model.name}'; + final uri = 'wss://${_modelUri.baseAuthority}/' + '$_apiUrl.$_apiVersion.$_apiUrlSuffix/' + '$_location?key=${_app.options.apiKey}'; + final modelString = 'projects/${_app.options.projectId}/' + 'locations/$_location/publishers/google/models/${model.name}'; final setupJson = { 'setup': { @@ -83,7 +89,10 @@ final class LiveGenerativeModel extends BaseModel { final request = jsonEncode(setupJson); final headers = await BaseModel.firebaseTokens(_appCheck, _auth, _app)(); - var ws = IOWebSocketChannel.connect(Uri.parse(uri), headers: headers); + + var ws = kIsWeb + ? WebSocketChannel.connect(Uri.parse(uri)) + : IOWebSocketChannel.connect(Uri.parse(uri), headers: headers); await ws.ready; ws.sink.add(request); diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/live_session.dart b/packages/firebase_ai/firebase_ai/lib/src/live_session.dart similarity index 98% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/live_session.dart rename to packages/firebase_ai/firebase_ai/lib/src/live_session.dart index a7b5a3108214..34835ff11247 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/live_session.dart +++ b/packages/firebase_ai/firebase_ai/lib/src/live_session.dart @@ -92,7 +92,7 @@ class LiveSession { await _sendMediaChunk(chunk); } } catch (e) { - throw VertexAISdkException(e.toString()); + throw FirebaseAISdkException(e.toString()); } finally { log('Stream processing completed.'); } diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/schema.dart b/packages/firebase_ai/firebase_ai/lib/src/schema.dart similarity index 100% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/schema.dart rename to packages/firebase_ai/firebase_ai/lib/src/schema.dart diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/utils/mutex.dart b/packages/firebase_ai/firebase_ai/lib/src/utils/mutex.dart similarity index 100% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/utils/mutex.dart rename to packages/firebase_ai/firebase_ai/lib/src/utils/mutex.dart diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/vertex_version.dart b/packages/firebase_ai/firebase_ai/lib/src/vertex_version.dart similarity index 100% rename from packages/firebase_vertexai/firebase_vertexai/lib/src/vertex_version.dart rename to packages/firebase_ai/firebase_ai/lib/src/vertex_version.dart diff --git a/packages/firebase_ai/firebase_ai/pubspec.yaml b/packages/firebase_ai/firebase_ai/pubspec.yaml new file mode 100644 index 000000000000..fea4be4a4f49 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/pubspec.yaml @@ -0,0 +1,39 @@ +name: firebase_ai +description: Firebase AI SDK. +version: 2.0.0 +homepage: https://firebase.google.com/docs/vertex-ai/get-started?platform=flutter +topics: + - firebase + - vertexai + - gemini + - generative-ai + +# Explicit about the supported platforms. +platforms: + android: + ios: + macos: + web: + +environment: + sdk: '>=3.2.0 <4.0.0' + flutter: ">=3.16.0" + +dependencies: + firebase_app_check: ^0.3.2+5 + firebase_auth: ^5.5.2 + firebase_core: ^3.13.0 + firebase_core_platform_interface: ^5.3.1 + flutter: + sdk: flutter + http: ^1.1.0 + meta: ^1.15.0 + web_socket_channel: ^3.0.1 + +dev_dependencies: + flutter_lints: ^4.0.0 + flutter_test: + sdk: flutter + matcher: ^0.12.16 + mockito: ^5.0.0 + plugin_platform_interface: ^2.1.3 diff --git a/packages/firebase_vertexai/firebase_vertexai/test/base_model_test.dart b/packages/firebase_ai/firebase_ai/test/base_model_test.dart similarity index 64% rename from packages/firebase_vertexai/firebase_vertexai/test/base_model_test.dart rename to packages/firebase_ai/firebase_ai/test/base_model_test.dart index 6ca5b0e251ff..456111126c85 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/base_model_test.dart +++ b/packages/firebase_ai/firebase_ai/test/base_model_test.dart @@ -11,12 +11,12 @@ // 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 'dart:'; + +import 'package:firebase_ai/src/base_model.dart'; +import 'package:firebase_ai/src/client.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:firebase_vertexai/src/base_model.dart'; -import 'package:firebase_vertexai/src/client.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/mockito.dart'; @@ -69,59 +69,8 @@ class MockApiClient extends Mock implements ApiClient { } } -// A concrete subclass of BaseModel for testing purposes -class TestBaseModel extends BaseModel { - TestBaseModel({ - required String model, - required String location, - required FirebaseApp app, - }) : super(model: model, location: location, app: app); -} - -class TestApiClientModel extends BaseApiClientModel { - TestApiClientModel({ - required super.model, - required super.location, - required super.app, - required ApiClient client, - }) : super(client: client); -} - void main() { group('BaseModel', () { - test('normalizeModelName returns correct prefix and name for model code', - () { - final result = BaseModel.normalizeModelName('models/my-model'); - expect(result.prefix, 'models'); - expect(result.name, 'my-model'); - }); - - test( - 'normalizeModelName returns correct prefix and name for user-friendly name', - () { - final result = BaseModel.normalizeModelName('my-model'); - expect(result.prefix, 'models'); - expect(result.name, 'my-model'); - }); - - test('taskUri constructs the correct URI for a task', () { - final mockApp = MockFirebaseApp(); - final model = TestBaseModel( - model: 'my-model', location: 'us-central1', app: mockApp); - final taskUri = model.taskUri(Task.generateContent); - expect(taskUri.toString(), - 'https://firebasevertexai.googleapis.com/v1beta/projects/test-project/locations/us-central1/publishers/google/models/my-model:generateContent'); - }); - - test('taskUri constructs the correct URI for a task with model code', () { - final mockApp = MockFirebaseApp(); - final model = TestBaseModel( - model: 'models/my-model', location: 'us-central1', app: mockApp); - final taskUri = model.taskUri(Task.countTokens); - expect(taskUri.toString(), - 'https://firebasevertexai.googleapis.com/v1beta/projects/test-project/locations/us-central1/publishers/google/models/my-model:countTokens'); - }); - test('firebaseTokens returns a function that generates headers', () async { final tokenFunction = BaseModel.firebaseTokens(null, null, null); final headers = await tokenFunction(); @@ -189,33 +138,4 @@ void main() { expect(headers.length, 4); }); }); - - group('BaseApiClientModel', () { - test('makeRequest returns the parsed response', () async { - final mockApp = MockFirebaseApp(); - final mockClient = MockApiClient(); - final model = TestApiClientModel( - model: 'test-model', - location: 'us-central1', - app: mockApp, - client: mockClient); - final params = {'input': 'test'}; - const task = Task.generateContent; - - final response = await model.makeRequest( - task, params, (data) => data['mockResponse']! as String); - expect(response, 'success'); - }); - - test('client getter returns the injected ApiClient', () { - final mockApp = MockFirebaseApp(); - final mockClient = MockApiClient(); - final model = TestApiClientModel( - model: 'test-model', - location: 'us-central1', - app: mockApp, - client: mockClient); - expect(model.client, mockClient); - }); - }); } diff --git a/packages/firebase_ai/firebase_ai/test/chat_test.dart b/packages/firebase_ai/firebase_ai/test/chat_test.dart new file mode 100644 index 000000000000..ab5819f0f12a --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/chat_test.dart @@ -0,0 +1,132 @@ +// Copyright 2024 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/firebase_ai.dart'; +import 'package:firebase_ai/src/base_model.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; +import 'utils/matchers.dart'; +import 'utils/stub_client.dart'; + +void main() { + setupFirebaseVertexAIMocks(); + // ignore: unused_local_variable + late FirebaseApp app; + + group('Chat', () { + const defaultModelName = 'some-model'; + setUpAll(() async { + // Initialize Firebase + app = await Firebase.initializeApp(); + }); + + (ClientController, GenerativeModel) createModel([ + String modelName = defaultModelName, + ]) { + final client = ClientController(); + final model = createModelWithClient( + app: app, + useVertexBackend: true, + model: modelName, + client: client.client, + location: 'us-central1'); + return (client, model); + } + + test('includes chat history in prompt', () async { + final (client, model) = createModel('models/$defaultModelName'); + final chat = model.startChat(history: [ + Content.text('Hi!'), + Content.model([TextPart('Hello, how can I help you today?')]), + ]); + const prompt = 'Some prompt'; + final response = await client.checkRequest( + () => chat.sendMessage(Content.text(prompt)), + verifyRequest: (_, request) { + final contents = request['contents']; + expect(contents, hasLength(3)); + }, + response: arbitraryGenerateContentResponse, + ); + expect( + chat.history.last, + matchesContent(response.candidates.first.content), + ); + }); + + test('forwards safety settings', () async { + final (client, model) = createModel('models/$defaultModelName'); + final chat = model.startChat(safetySettings: [ + SafetySetting(HarmCategory.dangerousContent, HarmBlockThreshold.high, + HarmBlockMethod.severity), + ]); + const prompt = 'Some prompt'; + await client.checkRequest( + () => chat.sendMessage(Content.text(prompt)), + verifyRequest: (_, request) { + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + 'method': 'SEVERITY' + }, + ]); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('forwards safety settings and config when streaming', () async { + final (client, model) = createModel('models/$defaultModelName'); + final chat = model.startChat(safetySettings: [ + SafetySetting(HarmCategory.dangerousContent, HarmBlockThreshold.high, + HarmBlockMethod.probability), + ], generationConfig: GenerationConfig(stopSequences: ['a'])); + const prompt = 'Some prompt'; + final responses = await client.checkStreamRequest( + () async => chat.sendMessageStream(Content.text(prompt)), + verifyRequest: (_, request) { + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + 'method': 'PROBABILITY', + }, + ]); + }, + responses: [arbitraryGenerateContentResponse], + ); + await responses.drain(); + }); + + test('forwards generation config', () async { + final (client, model) = createModel('models/$defaultModelName'); + final chat = model.startChat( + generationConfig: GenerationConfig(stopSequences: ['a']), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => chat.sendMessage(Content.text(prompt)), + verifyRequest: (_, request) { + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/content_test.dart b/packages/firebase_ai/firebase_ai/test/content_test.dart new file mode 100644 index 000000000000..fc2957d89d73 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/content_test.dart @@ -0,0 +1,215 @@ +// Copyright 2024 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 'dart:convert'; +import 'dart:typed_data'; + +import 'package:firebase_ai/src/content.dart'; +import 'package:firebase_ai/src/error.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Mock google_ai classes (if needed) +// ... + +void main() { + group('Content tests', () { + test('constructor', () { + final content = Content('user', + [TextPart('Test'), InlineDataPart('image/png', Uint8List(0))]); + expect(content.role, 'user'); + expect(content.parts[0], isA()); + expect((content.parts[0] as TextPart).text, 'Test'); + expect(content.parts[1], isA()); + expect((content.parts[1] as InlineDataPart).mimeType, 'image/png'); + expect((content.parts[1] as InlineDataPart).bytes.length, 0); + }); + + test('text()', () { + final content = Content('user', [TextPart('Test')]); + expect(content.role, 'user'); + expect(content.parts[0], isA()); + }); + + test('data()', () { + final content = + Content('user', [InlineDataPart('image/png', Uint8List(0))]); + expect(content.parts[0], isA()); + }); + + test('multi()', () { + final content = Content('user', + [TextPart('Test'), InlineDataPart('image/png', Uint8List(0))]); + expect(content.parts.length, 2); + expect(content.parts[0], isA()); + expect(content.parts[1], isA()); + }); + + test('toJson', () { + final content = Content('user', + [TextPart('Test'), InlineDataPart('image/png', Uint8List(0))]); + final json = content.toJson(); + expect(json['role'], 'user'); + expect((json['parts']! as List).length, 2); + expect((json['parts']! as List)[0]['text'], 'Test'); + expect( + (json['parts']! as List)[1]['inlineData']['mimeType'], 'image/png'); + expect((json['parts']! as List)[1]['inlineData']['data'].length, 0); + }); + + test('parseContent', () { + final json = { + 'role': 'user', + 'parts': [ + {'text': 'Hello'}, + ] + }; + final content = parseContent(json); + expect(content.role, 'user'); + expect(content.parts.length, 1); + expect(content.parts[0], isA()); + expect(reason: 'TextPart', (content.parts[0] as TextPart).text, 'Hello'); + }); + }); + + group('Part tests', () { + test('TextPart toJson', () { + final part = TextPart('Test'); + final json = part.toJson(); + expect((json as Map)['text'], 'Test'); + }); + + test('DataPart toJson', () { + final part = InlineDataPart('image/png', Uint8List(0)); + final json = part.toJson(); + expect((json as Map)['inlineData']['mimeType'], 'image/png'); + expect(json['inlineData']['data'], ''); + }); + + test('FunctionCall toJson', () { + final part = FunctionCall( + 'myFunction', + { + 'arguments': [ + {'text': 'Test'} + ], + }, + id: 'myFunctionId'); + final json = part.toJson(); + expect((json as Map)['functionCall']['name'], 'myFunction'); + expect(json['functionCall']['args'].length, 1); + expect(json['functionCall']['args']['arguments'].length, 1); + expect(json['functionCall']['args']['arguments'][0]['text'], 'Test'); + expect(json['functionCall']['id'], 'myFunctionId'); + }); + + test('FunctionResponse toJson', () { + final part = FunctionResponse( + 'myFunction', + { + 'inlineData': { + 'mimeType': 'application/octet-stream', + 'data': Uint8List(0) + } + }, + id: 'myFunctionId'); + final json = part.toJson(); + expect((json as Map)['functionResponse']['name'], 'myFunction'); + expect(json['functionResponse']['response']['inlineData']['mimeType'], + 'application/octet-stream'); + expect(json['functionResponse']['response']['inlineData']['data'], + Uint8List(0)); + expect(json['functionResponse']['id'], 'myFunctionId'); + }); + + test('FileData toJson', () { + final part = FileData('image/png', 'gs://bucket-name/path'); + final json = part.toJson(); + expect((json as Map)['file_data']['mime_type'], 'image/png'); + expect(json['file_data']['file_uri'], 'gs://bucket-name/path'); + }); + }); + + group('parsePart', () { + test('parses TextPart correctly', () { + final json = {'text': 'Hello, world!'}; + final result = parsePart(json); + expect(result, isA()); + expect((result as TextPart).text, 'Hello, world!'); + }); + + test('parses FunctionCall correctly', () { + final json = { + 'functionCall': { + 'name': 'myFunction', + 'args': {'arg1': 1, 'arg2': 'value'}, + 'id': '123', + } + }; + final result = parsePart(json); + expect(result, isA()); + final functionCall = result as FunctionCall; + expect(functionCall.name, 'myFunction'); + expect(functionCall.args, {'arg1': 1, 'arg2': 'value'}); + expect(functionCall.id, '123'); + }); + + test('parses FileData correctly', () { + final json = { + 'file_data': { + 'file_uri': 'file:///path/to/file.txt', + 'mime_type': 'text/plain', + } + }; + final result = parsePart(json); + expect(result, isA()); + final fileData = result as FileData; + expect(fileData.fileUri, 'file:///path/to/file.txt'); + expect(fileData.mimeType, 'text/plain'); + }); + + test('parses InlineDataPart correctly', () { + final json = { + 'inlineData': { + 'mimeType': 'image/png', + 'data': base64Encode([1, 2, 3]) + } + }; + final result = parsePart(json); + expect(result, isA()); + final inlineData = result as InlineDataPart; + expect(inlineData.mimeType, 'image/png'); + expect(inlineData.bytes, [1, 2, 3]); + }); + + test('throws UnimplementedError for functionResponse', () { + final json = { + 'functionResponse': {'name': 'test', 'response': {}} + }; + expect(() => parsePart(json), throwsA(isA())); + }); + + test('throws unhandledFormat for invalid JSON', () { + final json = {'invalid': 'data'}; + expect(() => parsePart(json), throwsA(isA())); + }); + + test('throws unhandledFormat for null input', () { + expect(() => parsePart(null), throwsA(isA())); + }); + + test('throws unhandledFormat for empty map', () { + expect(() => parsePart({}), throwsA(isA())); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/error_test.dart b/packages/firebase_ai/firebase_ai/test/error_test.dart new file mode 100644 index 000000000000..5fb5a1370de0 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/error_test.dart @@ -0,0 +1,165 @@ +// Copyright 2025 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/error.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('VertexAI Exceptions', () { + test('VertexAIException toString', () { + final exception = FirebaseAIException('Test message'); + expect(exception.toString(), 'VertexAIException: Test message'); + }); + + test('InvalidApiKey toString', () { + final exception = InvalidApiKey('Invalid API key provided.'); + expect(exception.toString(), 'Invalid API key provided.'); + }); + + test('UnsupportedUserLocation message', () { + final exception = UnsupportedUserLocation(); + expect( + exception.message, 'User location is not supported for the API use.'); + }); + + test('ServiceApiNotEnabled message', () { + final exception = ServiceApiNotEnabled('projects/test-project'); + expect( + exception.message, + 'The Vertex AI in Firebase SDK requires the Vertex AI in Firebase API ' + '(`firebasevertexai.googleapis.com`) to be enabled in your Firebase project. Enable this API ' + 'by visiting the Firebase Console at ' + 'https://console.firebase.google.com/project/test-project/genai ' + 'and clicking "Get started". If you enabled this API recently, wait a few minutes for the ' + 'action to propagate to our systems and then retry.'); + }); + + test('QuotaExceeded toString', () { + final exception = QuotaExceeded('Quota for this API has been exceeded.'); + expect(exception.toString(), 'Quota for this API has been exceeded.'); + }); + + test('ServerException toString', () { + final exception = ServerException('Server error occurred.'); + expect(exception.toString(), 'Server error occurred.'); + }); + + test('VertexAISdkException toString', () { + final exception = FirebaseAISdkException('SDK failed to parse response.'); + expect( + exception.toString(), + 'SDK failed to parse response.\n' + 'This indicates a problem with the Vertex AI in Firebase SDK. ' + 'Try updating to the latest version ' + '(https://pub.dev/packages/firebase_ai/versions), ' + 'or file an issue at ' + 'https://github.com/firebase/flutterfire/issues.'); + }); + + test('ImagenImagesBlockedException toString', () { + final exception = + ImagenImagesBlockedException('All images were blocked.'); + expect(exception.toString(), 'All images were blocked.'); + }); + + test('LiveWebSocketClosedException toString - DEADLINE_EXCEEDED', () { + final exception = LiveWebSocketClosedException( + 'DEADLINE_EXCEEDED: Connection timed out.'); + expect(exception.toString(), + 'The current live session has expired. Please start a new session.'); + }); + + test('LiveWebSocketClosedException toString - RESOURCE_EXHAUSTED', () { + final exception = LiveWebSocketClosedException( + 'RESOURCE_EXHAUSTED: Too many connections.'); + expect( + exception.toString(), + 'You have exceeded the maximum number of concurrent sessions. ' + 'Please close other sessions and try again later.'); + }); + + test('LiveWebSocketClosedException toString - Other', () { + final exception = + LiveWebSocketClosedException('WebSocket connection closed.'); + expect(exception.toString(), 'WebSocket connection closed.'); + }); + + group('parseError', () { + test('parses API_KEY_INVALID', () { + final json = { + 'message': 'Invalid API key', + 'details': [ + {'reason': 'API_KEY_INVALID'} + ] + }; + final exception = parseError(json); + expect(exception, isInstanceOf()); + expect(exception.message, 'Invalid API key'); + }); + + test('parses UNSUPPORTED_USER_LOCATION', () { + final json = { + 'message': 'User location is not supported for the API use.' + }; + final exception = parseError(json); + expect(exception, isInstanceOf()); + }); + + test('parses QUOTA_EXCEEDED', () { + final json = {'message': 'Quota exceeded: Limit reached.'}; + final exception = parseError(json); + expect(exception, isInstanceOf()); + expect(exception.message, 'Quota exceeded: Limit reached.'); + }); + + test('parses SERVICE_API_NOT_ENABLED', () { + final json = { + 'message': 'API not enabled', + 'status': 'PERMISSION_DENIED', + 'details': [ + { + 'metadata': { + 'service': 'firebasevertexai.googleapis.com', + 'consumer': 'projects/my-project-id', + } + } + ] + }; + final exception = parseError(json); + expect(exception, isInstanceOf()); + expect( + (exception as ServiceApiNotEnabled).message, + 'The Vertex AI in Firebase SDK requires the Vertex AI in Firebase API ' + '(`firebasevertexai.googleapis.com`) to be enabled in your Firebase project. Enable this API ' + 'by visiting the Firebase Console at ' + 'https://console.firebase.google.com/project/my-project-id/genai ' + 'and clicking "Get started". If you enabled this API recently, wait a few minutes for the ' + 'action to propagate to our systems and then retry.'); + }); + + test('parses SERVER_ERROR', () { + final json = {'message': 'Internal server error.'}; + final exception = parseError(json); + expect(exception, isInstanceOf()); + expect(exception.message, 'Internal server error.'); + }); + + test('parses UNHANDLED_FORMAT', () { + final json = {'unexpected': 'format'}; + expect(() => parseError(json), + throwsA(isInstanceOf())); + }); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart b/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart new file mode 100644 index 000000000000..8edb2d0f8480 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/firebase_vertexai_test.dart @@ -0,0 +1,81 @@ +// Copyright 2024 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/firebase_ai.dart'; +import 'package:firebase_app_check/firebase_app_check.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; + +void main() { + setupFirebaseVertexAIMocks(); + // ignore: unused_local_variable + late FirebaseApp app; + // ignore: unused_local_variable + late FirebaseAppCheck appCheck; + late FirebaseApp customApp; + late FirebaseAppCheck customAppCheck; + + group('FirebaseAI Tests', () { + late FirebaseApp app; + + setUpAll(() async { + // Initialize Firebase + app = await Firebase.initializeApp(); + customApp = await Firebase.initializeApp( + name: 'custom-app', + options: Firebase.app().options, + ); + appCheck = FirebaseAppCheck.instance; + customAppCheck = FirebaseAppCheck.instanceFor(app: customApp); + }); + + test('Singleton behavior', () { + final instance1 = FirebaseAI.vertexAI(); + final instance2 = FirebaseAI.vertexAI(app: app); + expect(identical(instance1, instance2), isTrue); + }); + + test('Instance creation with defaults', () { + final vertexAI = FirebaseAI.vertexAI(app: app); + expect(vertexAI.app, equals(app)); + expect(vertexAI.location, equals('us-central1')); + }); + + test('Instance creation with custom', () { + final vertexAI = FirebaseAI.vertexAI( + app: customApp, + appCheck: customAppCheck, + location: 'custom-location'); + expect(vertexAI.app, equals(customApp)); + expect(vertexAI.appCheck, equals(customAppCheck)); + expect(vertexAI.location, equals('custom-location')); + }); + + test('generativeModel creation', () { + final vertexAI = FirebaseAI.vertexAI(); + + final model = vertexAI.generativeModel( + model: 'gemini-pro', + generationConfig: GenerationConfig(maxOutputTokens: 1024), + systemInstruction: Content.system('You are a helpful assistant.'), + ); + + expect(model, isA()); + }); + + // ... other tests (e.g., with different parameters) + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/google_ai_generative_model_test.dart b/packages/firebase_ai/firebase_ai/test/google_ai_generative_model_test.dart new file mode 100644 index 000000000000..9883102c2729 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/google_ai_generative_model_test.dart @@ -0,0 +1,731 @@ +// Copyright 2025 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/firebase_ai.dart'; +import 'package:firebase_ai/src/base_model.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; +import 'utils/matchers.dart'; +import 'utils/stub_client.dart'; + +void main() { + setupFirebaseVertexAIMocks(); + late FirebaseApp app; + setUpAll(() async { + // Initialize Firebase + app = await Firebase.initializeApp(); + }); + group('GenerativeModel', () { + const defaultModelName = 'some-model'; + + (ClientController, GenerativeModel) createModel({ + String modelName = defaultModelName, + List? tools, + ToolConfig? toolConfig, + Content? systemInstruction, + }) { + final client = ClientController(); + final model = createModelWithClient( + useVertexBackend: false, + app: app, + model: modelName, + client: client.client, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + location: 'us-central1'); + return (client, model); + } + + test('strips leading "models/" from model name', () async { + final (client, model) = createModel( + modelName: 'models/$defaultModelName', + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + response: arbitraryGenerateContentResponse, + verifyRequest: (uri, _) { + expect(uri.path, endsWith('/models/some-model:generateContent')); + }, + ); + }); + + test('allows specifying a tuned model', () async { + final (client, model) = createModel( + modelName: 'tunedModels/$defaultModelName', + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + response: arbitraryGenerateContentResponse, + verifyRequest: (uri, _) { + expect(uri.path, endsWith('/tunedModels/some-model:generateContent')); + }, + ); + }); + + test('allows specifying an API version', () async { + final (client, model) = createModel( + // requestOptions: RequestOptions(apiVersion: 'override_version'), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + response: arbitraryGenerateContentResponse, + verifyRequest: (uri, _) { + expect(uri.path, startsWith('/override_version/')); + }, + ); + }, skip: 'No support for overriding API version'); + + group('generate unary content', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + const result = 'Some response'; + final response = await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:generateContent', + ), + ); + expect(request, { + 'model': 'models/$defaultModelName', + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + }); + }, + response: { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [ + {'text': result}, + ], + }, + }, + ], + }, + ); + expect( + response, + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content('model', [TextPart(result)]), + null, + null, + null, + null, + ), + ], null), + ), + ); + }); + + test('can override safety settings', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent( + [Content.text(prompt)], + safetySettings: [ + SafetySetting( + HarmCategory.dangerousContent, + HarmBlockThreshold.high, + null, + ), + ], + ), + response: arbitraryGenerateContentResponse, + verifyRequest: (_, request) { + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + ]); + }, + ); + }); + + test('can override generation config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([ + Content.text(prompt), + ], generationConfig: GenerationConfig(stopSequences: ['a'])), + verifyRequest: (_, request) { + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can pass system instructions', () async { + const instructions = 'Do a good job'; + final (client, model) = createModel( + systemInstruction: Content.system(instructions), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['systemInstruction'], { + 'role': 'system', + 'parts': [ + {'text': instructions}, + ], + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can pass tools and function calling config', () async { + final (client, model) = createModel( + tools: [ + Tool.functionDeclarations([ + FunctionDeclaration( + 'someFunction', + 'Some cool function.', + parameters: { + 'schema1': Schema.string(description: 'Some parameter.'), + }, + ), + ]), + ], + toolConfig: ToolConfig( + functionCallingConfig: FunctionCallingConfig.any( + {'someFunction'}, + ), + ), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['tools'], [ + { + 'functionDeclarations': [ + { + 'name': 'someFunction', + 'description': 'Some cool function.', + 'parameters': { + 'type': 'OBJECT', + 'properties': { + 'schema1': { + 'type': 'STRING', + 'description': 'Some parameter.' + } + }, + 'required': ['schema1'] + } + }, + ], + }, + ]); + expect(request['toolConfig'], { + 'functionCallingConfig': { + 'mode': 'ANY', + 'allowedFunctionNames': ['someFunction'], + }, + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can override tools and function calling config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent( + [Content.text(prompt)], + tools: [ + Tool.functionDeclarations([ + FunctionDeclaration( + 'someFunction', + 'Some cool function.', + parameters: { + 'schema1': Schema.string(description: 'Some parameter.'), + }, + ), + ]), + ], + toolConfig: ToolConfig( + functionCallingConfig: FunctionCallingConfig.any( + {'someFunction'}, + ), + ), + ), + verifyRequest: (_, request) { + expect(request['tools'], [ + { + 'functionDeclarations': [ + { + 'name': 'someFunction', + 'description': 'Some cool function.', + 'parameters': { + 'type': 'OBJECT', + 'properties': { + 'schema1': { + 'type': 'STRING', + 'description': 'Some parameter.' + } + }, + 'required': ['schema1'] + } + }, + ], + }, + ]); + expect(request['toolConfig'], { + 'functionCallingConfig': { + 'mode': 'ANY', + 'allowedFunctionNames': ['someFunction'], + }, + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can enable code execution', () async { + final (client, model) = createModel(tools: [ + // Tool(codeExecution: CodeExecution()), + ]); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['tools'], [ + {'codeExecution': {}} + ]); + }, + response: arbitraryGenerateContentResponse, + ); + }, skip: 'No support for code executation'); + + test('can override code execution', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([ + Content.text(prompt) + ], tools: [ + // Tool(codeExecution: CodeExecution()), + ]), + verifyRequest: (_, request) { + expect(request['tools'], [ + {'codeExecution': {}} + ]); + }, + response: arbitraryGenerateContentResponse, + ); + }, skip: 'No support for code execution'); + }); + + group('generate content stream', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final results = {'First response', 'Second Response'}; + final response = await client.checkStreamRequest( + () async => model.generateContentStream([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:streamGenerateContent', + ), + ); + expect(request, { + 'model': 'models/$defaultModelName', + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + }); + }, + responses: [ + for (final result in results) + { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [ + {'text': result}, + ], + }, + }, + ], + }, + ], + ); + expect( + response, + emitsInOrder([ + for (final result in results) + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content('model', [TextPart(result)]), + null, + null, + null, + null, + ), + ], null), + ), + ]), + ); + }); + + test('can override safety settings', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final responses = await client.checkStreamRequest( + () async => model.generateContentStream( + [Content.text(prompt)], + safetySettings: [ + SafetySetting( + HarmCategory.dangerousContent, + HarmBlockThreshold.high, + null, + ), + ], + ), + verifyRequest: (_, request) { + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + ]); + }, + responses: [arbitraryGenerateContentResponse], + ); + await responses.drain(); + }); + + test('can override generation config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final responses = await client.checkStreamRequest( + () async => model.generateContentStream([ + Content.text(prompt), + ], generationConfig: GenerationConfig(stopSequences: ['a'])), + verifyRequest: (_, request) { + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + }, + responses: [arbitraryGenerateContentResponse], + ); + await responses.drain(); + }); + }); + + group('count tokens', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final response = await client.checkRequest( + () => model.countTokens([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:countTokens', + ), + ); + expect(request, { + 'generateContentRequest': { + 'model': 'models/$defaultModelName', + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + } + }); + }, + response: {'totalTokens': 2}, + ); + expect(response, matchesCountTokensResponse(CountTokensResponse(2))); + }); + + test('can override GenerateContentRequest fields', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + response: {'totalTokens': 100}, + () => model.countTokens( + [Content.text(prompt)], + // safetySettings: [ + // SafetySetting( + // HarmCategory.dangerousContent, + // HarmBlockThreshold.high, + // null, + // ), + // ], + // generationConfig: GenerationConfig(stopSequences: ['a']), + // tools: [ + // Tool(functionDeclarations: [ + // FunctionDeclaration( + // 'someFunction', + // 'Some cool function.', + // Schema(SchemaType.string, description: 'Some parameter.'), + // ), + // ]), + // ], + // toolConfig: ToolConfig( + // functionCallingConfig: FunctionCallingConfig( + // mode: FunctionCallingMode.any, + // allowedFunctionNames: {'someFunction'}, + // ), + // ), + ), + verifyRequest: (_, countTokensRequest) { + expect(countTokensRequest, isNotNull); + final request = countTokensRequest['generateContentRequest']! + as Map; + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + ]); + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + expect(request['tools'], [ + { + 'functionDeclarations': [ + { + 'name': 'someFunction', + 'description': 'Some cool function.', + 'parameters': { + 'type': 'STRING', + 'description': 'Some parameter.', + }, + }, + ], + }, + ]); + expect(request['toolConfig'], { + 'functionCallingConfig': { + 'mode': 'ANY', + 'allowedFunctionNames': ['someFunction'], + }, + }); + }, + ); + }, skip: 'Only content argument supported for countTokens'); + }); + + group('embed content', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final response = await client.checkRequest( + () async { + // await model.embedContent(Content.text(prompt)); + }, + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:embedContent', + ), + ); + expect(request, { + 'content': { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + }); + }, + response: { + 'embedding': { + 'values': [0.1, 0.2, 0.3], + }, + }, + ); + expect( + response, + // matchesEmbedContentResponse( + // EmbedContentResponse(ContentEmbedding([0.1, 0.2, 0.3])), + // ), + isNotNull, + ); + }); + + test('embed content with reduced output dimensionality', () async { + final (client, model) = createModel(); + const content = 'Some content'; + const outputDimensionality = 1; + final embeddingValues = [0.1]; + + await client.checkRequest(() async { + Content.text(content); + // await model.embedContent( + // Content.text(content), + // outputDimensionality: outputDimensionality, + // ); + }, verifyRequest: (_, request) { + expect(request, + containsPair('outputDimensionality', outputDimensionality)); + }, response: { + 'embedding': {'values': embeddingValues}, + }); + }); + }, skip: 'No support for embedding content'); + + group('batch embed contents', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt1 = 'Some prompt'; + const prompt2 = 'Another prompt'; + final embedding1 = [0.1, 0.2, 0.3]; + final embedding2 = [0.4, 0.5, 1.6]; + final response = await client.checkRequest( + () async { + // await model.batchEmbedContents([ + // EmbedContentRequest(Content.text(prompt1)), + // EmbedContentRequest(Content.text(prompt2)), + // ]); + }, + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:batchEmbedContents', + ), + ); + expect(request, { + 'requests': [ + { + 'content': { + 'role': 'user', + 'parts': [ + {'text': prompt1}, + ], + }, + 'model': 'models/$defaultModelName', + }, + { + 'content': { + 'role': 'user', + 'parts': [ + {'text': prompt2}, + ], + }, + 'model': 'models/$defaultModelName', + }, + ], + }); + }, + response: { + 'embeddings': [ + {'values': embedding1}, + {'values': embedding2}, + ], + }, + ); + expect( + response, + isNotNull, + // matchesBatchEmbedContentsResponse( + // BatchEmbedContentsResponse([ + // ContentEmbedding(embedding1), + // ContentEmbedding(embedding2), + // ]), + // ), + ); + }); + + test('batch embed contents with reduced output dimensionality', () async { + final (client, model) = createModel(); + const content1 = 'Some content 1'; + const content2 = 'Some content 2'; + const outputDimensionality = 1; + final embeddingValues1 = [0.1]; + final embeddingValues2 = [0.4]; + + await client.checkRequest(() async { + Content.text(content1); + Content.text(content2); + // await model.batchEmbedContents([ + // EmbedContentRequest( + // Content.text(content1), + // outputDimensionality: outputDimensionality, + // ), + // EmbedContentRequest( + // Content.text(content2), + // outputDimensionality: outputDimensionality, + // ), + // ]); + }, verifyRequest: (_, request) { + expect(request['requests'], [ + containsPair('outputDimensionality', outputDimensionality), + containsPair('outputDimensionality', outputDimensionality), + ]); + }, response: { + 'embeddings': [ + {'values': embeddingValues1}, + {'values': embeddingValues2}, + ], + }); + }); + }, skip: 'No support for embed content'); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/google_ai_response_parsing_test.dart b/packages/firebase_ai/firebase_ai/test/google_ai_response_parsing_test.dart new file mode 100644 index 000000000000..eec1b98938b4 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/google_ai_response_parsing_test.dart @@ -0,0 +1,770 @@ +// Copyright 2025 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 'dart:convert'; + +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:firebase_ai/src/developer/api.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/matchers.dart'; + +void main() { + group('throws errors for invalid GenerateContentResponse', () { + test('with empty content', () { + const response = ''' +{ + "candidates": [ + { + "content": {}, + "index": 0 + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + expect( + () => DeveloperSerialization().parseGenerateContentResponse(decoded), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Unhandled format for Content:'), + ), + ), + ); + }); + + test('with a blocked prompt', () { + const response = ''' +{ + "promptFeedback": { + "blockReason": "SAFETY", + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "HIGH" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [], + PromptFeedback(BlockReason.safety, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.high), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + expect( + () => generateContentResponse.text, + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Response was blocked due to safety'), + ), + ), + ); + }); + }); + + group('parses successful GenerateContentResponse', () { + test('with a basic reply', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Mountain View, California, United States" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([ + TextPart('Mountain View, California, United States'), + ]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + null, + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('with a citation', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "placeholder" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "citationMetadata": { + "citationSources": [ + { + "startIndex": 574, + "endIndex": 705, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026 + }, + { + "uri": "https://example.com/", + "license": "" + }, + {} + ] + } + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([TextPart('placeholder')]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([ + Citation(574, 705, Uri.https('example.com'), ''), + Citation(899, 1026, Uri.https('example.com'), ''), + ]), + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('with a vertex formatted citation', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "placeholder" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "citationMetadata": { + "citations": [ + { + "startIndex": 574, + "endIndex": 705, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026 + }, + { + "uri": "https://example.com/", + "license": "" + }, + {} + ] + } + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([TextPart('placeholder')]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([ + Citation(574, 705, Uri.https('example.com'), ''), + Citation(899, 1026, Uri.https('example.com'), ''), + ]), + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('with code execution', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "executableCode": { + "language": "PYTHON", + "code": "print('hello world')" + } + }, + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "hello world" + } + }, + { + "text": "hello world" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([ + // ExecutableCode(Language.python, 'print(\'hello world\')'), + // CodeExecutionResult(Outcome.ok, 'hello world'), + TextPart('hello world') + ]), + [], + null, + FinishReason.stop, + null, + ), + ], + null, + ), + ), + ); + }, skip: 'Code Execution Unsupported'); + + test('allows missing content', () async { + const response = ''' +{ + "candidates": [ + { + "finishReason": "SAFETY", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "LOW" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "MEDIUM" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content(null, []), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating( + HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([]), + FinishReason.safety, + null), + ], null), + ), + ); + }); + + test('text getter joins content', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Initial text" + }, + { + "functionCall": {"name": "someFunction", "args": {}} + }, + { + "text": " And more text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect(generateContentResponse.text, 'Initial text And more text'); + expect(generateContentResponse.candidates.single.text, + 'Initial text And more text'); + }); + }); + + group('parses and throws error responses', () { + test('for invalid API key', () async { + const response = ''' +{ + "error": { + "code": 400, + "message": "API key not valid. Please pass a valid API key.", + "status": "INVALID_ARGUMENT", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "API_KEY_INVALID", + "domain": "googleapis.com", + "metadata": { + "service": "generativelanguage.googleapis.com" + } + }, + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "Invalid API key: AIzv00G7VmUCUeC-5OglO3hcXM" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + 'API key not valid. Please pass a valid API key.', + ), + ); + expect( + () => DeveloperSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => DeveloperSerialization().parseCountTokensResponse(decoded), + expectedThrow); + // expect(() => parseEmbedContentResponse(decoded), expectedThrow); + }); + + test('for unsupported user location', () async { + const response = r''' +{ + "error": { + "code": 400, + "message": "User location is not supported for the API use.", + "status": "FAILED_PRECONDITION", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::failed_precondition: User location is not supported for the API use. [google.rpc.error_details_ext] { message: \"User location is not supported for the API use.\" }" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + 'User location is not supported for the API use.', + ), + ); + expect( + () => DeveloperSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => DeveloperSerialization().parseCountTokensResponse(decoded), + expectedThrow); + // expect(() => parseEmbedContentResponse(decoded), expectedThrow); + }); + + test('for general server errors', () async { + const response = r''' +{ + "error": { + "code": 404, + "message": "models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.", + "status": "NOT_FOUND", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::not_found: models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods. [google.rpc.error_details_ext] { message: \"models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.\" }" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith( + 'models/unknown is not found for API version v1, ' + 'or is not supported for GenerateContent.', + ), + ), + ); + expect( + () => DeveloperSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => DeveloperSerialization().parseCountTokensResponse(decoded), + expectedThrow); + // expect(() => parseEmbedContentResponse(decoded), expectedThrow); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/imagen_test.dart b/packages/firebase_ai/firebase_ai/test/imagen_test.dart new file mode 100644 index 000000000000..4bd7ae5b763a --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/imagen_test.dart @@ -0,0 +1,241 @@ +// Copyright 2025 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 'dart:convert'; +import 'dart:typed_data'; + +import 'package:firebase_ai/src/error.dart'; +import 'package:firebase_ai/src/imagen_content.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('ImagenInlineImage', () { + test('fromJson with valid base64', () { + final json = { + 'mimeType': 'image/png', + 'bytesBase64Encoded': + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + }; + final image = ImagenInlineImage.fromJson(json); + expect(image.mimeType, 'image/png'); + expect(image.bytesBase64Encoded, isA()); + expect(image.bytesBase64Encoded, isNotEmpty); + }); + + test('fromJson with invalid base64', () { + final json = { + 'mimeType': 'image/png', + 'bytesBase64Encoded': 'invalid_base64_string' + }; + // Expect that the constructor throws an exception. + expect(() => ImagenInlineImage.fromJson(json), throwsFormatException); + }); + + test('toJson', () { + final image = ImagenInlineImage( + mimeType: 'image/png', + bytesBase64Encoded: Uint8List.fromList(utf8.encode('Hello, world!')), + ); + final json = image.toJson(); + expect(json, { + 'mimeType': 'image/png', + 'bytesBase64Encoded': 'SGVsbG8sIHdvcmxkIQ==', + }); + }); + }); + + group('ImagenGCSImage', () { + test('fromJson', () { + final json = { + 'mimeType': 'image/jpeg', + 'gcsUri': + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_0.jpg' + }; + final image = ImagenGCSImage.fromJson(json); + expect(image.mimeType, 'image/jpeg'); + expect(image.gcsUri, + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_0.jpg'); + }); + + test('toJson', () { + final image = ImagenGCSImage( + mimeType: 'image/jpeg', + gcsUri: + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_0.jpg', + ); + final json = image.toJson(); + expect(json, { + 'mimeType': 'image/jpeg', + 'gcsUri': + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_0.jpg', + }); + }); + }); + + group('ImagenGenerationResponse', () { + test('fromJson with gcsUri', () { + final json = { + 'predictions': [ + { + 'mimeType': 'image/jpeg', + 'gcsUri': + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_0.jpg' + }, + { + 'mimeType': 'image/jpeg', + 'gcsUri': + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_1.jpg' + }, + { + 'mimeType': 'image/jpeg', + 'gcsUri': + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_2.jpg' + }, + { + 'mimeType': 'image/jpeg', + 'gcsUri': + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_3.jpg' + } + ] + }; + final response = ImagenGenerationResponse.fromJson(json); + expect(response.images, isA>()); + expect(response.images.length, 4); + expect(response.filteredReason, isNull); + }); + + test('fromJson with bytesBase64Encoded', () { + final json = { + 'predictions': [ + { + 'mimeType': 'image/jpeg', + 'bytesBase64Encoded': 'SGVsbG8sIHdvcmxkIQ==' + }, + { + 'mimeType': 'image/jpeg', + 'bytesBase64Encoded': 'SGVsbG8sIHdvcmxkIQ==' + }, + { + 'mimeType': 'image/jpeg', + 'bytesBase64Encoded': 'SGVsbG8sIHdvcmxkIQ==' + }, + { + 'mimeType': 'image/jpeg', + 'bytesBase64Encoded': 'SGVsbG8sIHdvcmxkIQ==' + } + ] + }; + final response = + ImagenGenerationResponse.fromJson(json); + expect(response.images, isA>()); + expect(response.images.length, 4); + expect(response.filteredReason, isNull); + }); + + test('fromJson with bytesBase64Encoded and raiFilteredReason', () { + final json = { + 'predictions': [ + { + 'mimeType': 'image/png', + 'bytesBase64Encoded': + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + }, + { + 'mimeType': 'image/png', + 'bytesBase64Encoded': + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' + }, + { + 'raiFilteredReason': + 'Your current safety filter threshold filtered out 2 generated images. You will not be charged for blocked images. Try rephrasing the prompt. If you think this was an error, send feedback.' + } + ] + }; + final response = + ImagenGenerationResponse.fromJson(json); + expect(response.images, isA>()); + expect(response.images.length, 2); + expect(response.filteredReason, + 'Your current safety filter threshold filtered out 2 generated images. You will not be charged for blocked images. Try rephrasing the prompt. If you think this was an error, send feedback.'); + }); + + test('fromJson with only raiFilteredReason', () { + final json = { + 'predictions': [ + { + 'raiFilteredReason': + "Unable to show generated images. All images were filtered out because they violated Vertex AI's usage guidelines. You will not be charged for blocked images. Try rephrasing the prompt. If you think this was an error, send feedback. Support codes: 39322892, 29310472" + } + ] + }; + // Expect that the constructor throws an exception. + expect(() => ImagenGenerationResponse.fromJson(json), + throwsA(isA())); + }); + + test('fromJson with empty predictions', () { + final json = {'predictions': {}}; + // Expect that the constructor throws an exception. + expect(() => ImagenGenerationResponse.fromJson(json), + throwsA(isA())); + }); + + test('fromJson with unsupported type', () { + final json = { + 'predictions': [ + { + 'mimeType': 'image/jpeg', + 'gcsUri': + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_0.jpg' + }, + ] + }; + // Expect that the constructor throws an exception. + expect(() => ImagenGenerationResponse.fromJson(json), + throwsA(isA())); + }); + }); + + group('parseImagenGenerationResponse', () { + test('with valid response', () { + final json = { + 'predictions': [ + { + 'mimeType': 'image/jpeg', + 'gcsUri': + 'gs://test-project-id-1234.firebasestorage.app/images/1234567890123/sample_0.jpg' + }, + ] + }; + final response = parseImagenGenerationResponse(json); + expect(response.images, isA>()); + expect(response.images.length, 1); + expect(response.filteredReason, isNull); + }); + + test('with error', () { + final json = { + 'error': { + 'code': 400, + 'message': + "Image generation failed with the following error: The prompt could not be submitted. This prompt contains sensitive words that violate Google's Responsible AI practices. Try rephrasing the prompt. If you think this was an error, send feedback. Support codes: 42876398", + 'status': 'INVALID_ARGUMENT' + } + }; + // Expect that the function throws an exception. + expect(() => parseImagenGenerationResponse(json), + throwsA(isA())); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/live_test.dart b/packages/firebase_ai/firebase_ai/test/live_test.dart new file mode 100644 index 000000000000..090a2cbda469 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/live_test.dart @@ -0,0 +1,240 @@ +// Copyright 2025 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 'dart:typed_data'; + +import 'package:firebase_ai/src/api.dart'; +import 'package:firebase_ai/src/content.dart'; +import 'package:firebase_ai/src/error.dart'; +import 'package:firebase_ai/src/live_api.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('LiveAPI Tests', () { + test('SpeechConfig toJson() returns correct JSON', () { + final speechConfigWithVoice = SpeechConfig(voiceName: 'Aoede'); + expect(speechConfigWithVoice.toJson(), { + 'voice_config': { + 'prebuilt_voice_config': {'voice_name': 'Aoede'} + } + }); + + final speechConfigWithoutVoice = SpeechConfig(); + expect(speechConfigWithoutVoice.toJson(), {}); + }); + + test('ResponseModalities enum toJson() returns correct value', () { + expect(ResponseModalities.text.toJson(), 'TEXT'); + expect(ResponseModalities.image.toJson(), 'IMAGE'); + expect(ResponseModalities.audio.toJson(), 'AUDIO'); + }); + + test('LiveGenerationConfig toJson() returns correct JSON', () { + final liveGenerationConfig = LiveGenerationConfig( + speechConfig: SpeechConfig(voiceName: 'Charon'), + responseModalities: [ResponseModalities.text, ResponseModalities.audio], + candidateCount: 2, + maxOutputTokens: 100, + temperature: 0.8, + topP: 0.95, + topK: 40, + ); + + expect(liveGenerationConfig.toJson(), { + 'candidateCount': 2, + 'maxOutputTokens': 100, + 'temperature': 0.8, + 'topP': 0.95, + 'topK': 40, + 'speechConfig': { + 'voice_config': { + 'prebuilt_voice_config': {'voice_name': 'Charon'} + } + }, + 'responseModalities': ['TEXT', 'AUDIO'], + }); + + final liveGenerationConfigWithoutOptionals = LiveGenerationConfig(); + expect(liveGenerationConfigWithoutOptionals.toJson(), {}); + }); + + test('LiveServerContent constructor and properties', () { + final content = Content.text('Hello, world!'); + final message = LiveServerContent( + modelTurn: content, + turnComplete: true, + interrupted: false, + ); + expect(message.modelTurn, content); + expect(message.turnComplete, true); + expect(message.interrupted, false); + + final message2 = LiveServerContent(); + expect(message2.modelTurn, null); + expect(message2.turnComplete, null); + expect(message2.interrupted, null); + }); + + test('LiveServerToolCall constructor and properties', () { + final functionCall = FunctionCall('test', {}); + final message = LiveServerToolCall(functionCalls: [functionCall]); + expect(message.functionCalls, [functionCall]); + + final message2 = LiveServerToolCall(); + expect(message2.functionCalls, null); + }); + + test('LiveServerToolCallCancellation constructor and properties', () { + final message = LiveServerToolCallCancellation(functionIds: ['1', '2']); + expect(message.functionIds, ['1', '2']); + + final message2 = LiveServerToolCallCancellation(); + expect(message2.functionIds, null); + }); + + test('LiveClientRealtimeInput toJson() returns correct JSON', () { + final part = InlineDataPart('audio/pcm', Uint8List.fromList([1, 2, 3])); + final message = LiveClientRealtimeInput(mediaChunks: [part]); + expect(message.toJson(), { + 'realtime_input': { + 'media_chunks': [ + { + 'mimeType': 'audio/pcm', + 'data': 'AQID', + } + ], + }, + }); + + final message2 = LiveClientRealtimeInput(); + expect(message2.toJson(), { + 'realtime_input': { + 'media_chunks': null, + }, + }); + }); + + test('LiveClientContent toJson() returns correct JSON', () { + final content = Content.text('some test input'); + final message = LiveClientContent(turns: [content], turnComplete: true); + expect(message.toJson(), { + 'client_content': { + 'turns': [ + { + 'role': 'user', + 'parts': [ + {'text': 'some test input'} + ] + } + ], + 'turn_complete': true, + } + }); + + final message2 = LiveClientContent(); + expect(message2.toJson(), { + 'client_content': { + 'turns': null, + 'turn_complete': null, + } + }); + }); + + test('LiveClientToolResponse toJson() returns correct JSON', () { + final response = FunctionResponse('test', {}); + final message = LiveClientToolResponse(functionResponses: [response]); + expect(message.toJson(), { + 'functionResponses': [ + { + 'functionResponse': {'name': 'test', 'response': {}} + } + ] + }); + + final message2 = LiveClientToolResponse(); + expect(message2.toJson(), {'functionResponses': null}); + }); + + test('parseServerMessage parses serverContent message correctly', () { + final jsonObject = { + 'serverContent': { + 'modelTurn': { + 'parts': [ + {'text': 'Hello, world!'} + ] + }, + 'turnComplete': true, + } + }; + final response = parseServerResponse(jsonObject); + expect(response.message, isA()); + final contentMessage = response.message as LiveServerContent; + expect(contentMessage.turnComplete, true); + expect(contentMessage.modelTurn, isA()); + }); + + test('parseServerMessage parses toolCall message correctly', () { + final jsonObject = { + 'toolCall': { + 'functionCalls': [ + { + 'name': 'test1', + 'args': {'foo1': 'bar1'} + }, + { + 'name': 'test2', + 'args': {'foo2': 'bar2'} + } + ] + } + }; + final response = parseServerResponse(jsonObject); + expect(response.message, isA()); + final toolCallMessage = response.message as LiveServerToolCall; + expect(toolCallMessage.functionCalls, isA>()); + }); + + test('parseServerMessage parses toolCallCancellation message correctly', + () { + final jsonObject = { + 'toolCallCancellation': { + 'ids': ['1', '2'] + } + }; + final response = parseServerResponse(jsonObject); + expect(response.message, isA()); + final cancellationMessage = + response.message as LiveServerToolCallCancellation; + expect(cancellationMessage.functionIds, ['1', '2']); + }); + + test('parseServerMessage parses setupComplete message correctly', () { + final jsonObject = {'setupComplete': {}}; + final response = parseServerResponse(jsonObject); + expect(response.message, isA()); + }); + + test('parseServerMessage throws VertexAIException for error message', () { + final jsonObject = {'error': {}}; + expect(() => parseServerResponse(jsonObject), + throwsA(isA())); + }); + + test('parseServerMessage throws VertexAISdkException for unhandled format', + () { + final jsonObject = {'unknown': {}}; + expect(() => parseServerResponse(jsonObject), + throwsA(isA())); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/mock.dart b/packages/firebase_ai/firebase_ai/test/mock.dart new file mode 100644 index 000000000000..ed883d924371 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/mock.dart @@ -0,0 +1,73 @@ +// Copyright 2024 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_core_platform_interface/firebase_core_platform_interface.dart'; +import 'package:firebase_core_platform_interface/test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +class MockFirebaseAppVertexAI implements TestFirebaseCoreHostApi { + @override + Future initializeApp( + String appName, + PigeonFirebaseOptions initializeAppRequest, + ) async { + return PigeonInitializeResponse( + name: appName, + options: initializeAppRequest, + pluginConstants: {}, + ); + } + + @override + Future> initializeCore() async { + return [ + PigeonInitializeResponse( + name: defaultFirebaseAppName, + options: PigeonFirebaseOptions( + apiKey: '123', + projectId: '123', + appId: '123', + messagingSenderId: '123', + ), + pluginConstants: {}, + ) + ]; + } + + @override + Future optionsFromResource() async { + return PigeonFirebaseOptions( + apiKey: '123', + projectId: '123', + appId: '123', + messagingSenderId: '123', + ); + } +} + +void setupFirebaseVertexAIMocks() { + TestWidgetsFlutterBinding.ensureInitialized(); + + TestFirebaseCoreHostApi.setup(MockFirebaseAppVertexAI()); +} + +// FirebaseVertexAIPlatform Mock +class MockFirebaseVertexAI extends Mock + with + // ignore: prefer_mixin, plugin_platform_interface needs to migrate to use `mixin` + MockPlatformInterfaceMixin { + MockFirebaseVertexAI(); +} diff --git a/packages/firebase_ai/firebase_ai/test/model_test.dart b/packages/firebase_ai/firebase_ai/test/model_test.dart new file mode 100644 index 000000000000..2ddf4d55406c --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/model_test.dart @@ -0,0 +1,464 @@ +// Copyright 2024 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/firebase_ai.dart'; +import 'package:firebase_ai/src/base_model.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; +import 'utils/matchers.dart'; +import 'utils/stub_client.dart'; + +void main() { + setupFirebaseVertexAIMocks(); + // ignore: unused_local_variable + late FirebaseApp app; + setUpAll(() async { + // Initialize Firebase + app = await Firebase.initializeApp(); + }); + group('GenerativeModel', () { + const defaultModelName = 'some-model'; + + (ClientController, GenerativeModel) createModel({ + String modelName = defaultModelName, + List? tools, + ToolConfig? toolConfig, + Content? systemInstruction, + }) { + final client = ClientController(); + final model = createModelWithClient( + useVertexBackend: true, + app: app, + model: modelName, + client: client.client, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + location: 'us-central1'); + return (client, model); + } + + test('strips leading "models/" from model name', () async { + final (client, model) = createModel( + modelName: 'models/$defaultModelName', + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + response: arbitraryGenerateContentResponse, + verifyRequest: (uri, _) { + expect(uri.path, endsWith('/models/some-model:generateContent')); + }, + ); + }); + + test('allows specifying a tuned model', () async { + final (client, model) = createModel( + modelName: 'tunedModels/$defaultModelName', + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + response: arbitraryGenerateContentResponse, + verifyRequest: (uri, _) { + expect(uri.path, endsWith('/tunedModels/some-model:generateContent')); + }, + ); + }); + + group('generate unary content', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + const result = 'Some response'; + final response = await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/projects/123/locations/us-central1/publishers/google/models/some-model:generateContent', + ), + ); + expect(request, { + 'model': 'models/$defaultModelName', + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + }); + }, + response: { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [ + {'text': result}, + ], + }, + }, + ], + }, + ); + expect( + response, + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content('model', [TextPart(result)]), + null, + null, + null, + null, + ), + ], null), + ), + ); + }); + + test('can override safety settings', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent( + [Content.text(prompt)], + safetySettings: [ + SafetySetting( + HarmCategory.dangerousContent, + HarmBlockThreshold.high, + HarmBlockMethod.probability, + ), + ], + ), + response: arbitraryGenerateContentResponse, + verifyRequest: (_, request) { + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + 'method': 'PROBABILITY', + }, + ]); + }, + ); + }); + + test('can override generation config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([ + Content.text(prompt), + ], generationConfig: GenerationConfig(stopSequences: ['a'])), + verifyRequest: (_, request) { + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can override GenerationConfig repetition penalties', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)], + generationConfig: GenerationConfig( + presencePenalty: 0.5, frequencyPenalty: 0.2)), + verifyRequest: (_, request) { + expect(request['generationConfig'], { + 'presencePenalty': 0.5, + 'frequencyPenalty': 0.2, + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can pass system instructions', () async { + const instructions = 'Do a good job'; + final (client, model) = createModel( + systemInstruction: Content.system(instructions), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['systemInstruction'], { + 'role': 'system', + 'parts': [ + {'text': instructions}, + ], + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can pass tools and function calling config', () async { + final (client, model) = createModel( + tools: [ + Tool.functionDeclarations([ + FunctionDeclaration( + 'someFunction', + 'Some cool function.', + parameters: { + 'schema1': Schema.string(description: 'Some parameter.') + }, + ), + ]), + ], + toolConfig: ToolConfig( + functionCallingConfig: FunctionCallingConfig.any( + {'someFunction'}, + ), + ), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['tools'], [ + { + 'functionDeclarations': [ + { + 'name': 'someFunction', + 'description': 'Some cool function.', + 'parameters': { + 'type': 'OBJECT', + 'properties': { + 'schema1': { + 'type': 'STRING', + 'description': 'Some parameter.' + } + }, + 'required': ['schema1'] + } + }, + ], + }, + ]); + expect(request['toolConfig'], { + 'functionCallingConfig': { + 'mode': 'ANY', + 'allowedFunctionNames': ['someFunction'], + }, + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can override tools and function calling config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent( + [Content.text(prompt)], + tools: [ + Tool.functionDeclarations([ + FunctionDeclaration( + 'someFunction', + 'Some cool function.', + parameters: { + 'schema1': Schema.string(description: 'Some parameter.') + }, + ), + ]), + ], + toolConfig: ToolConfig( + functionCallingConfig: FunctionCallingConfig.any( + {'someFunction'}, + ), + ), + ), + verifyRequest: (_, request) { + expect(request['tools'], [ + { + 'functionDeclarations': [ + { + 'name': 'someFunction', + 'description': 'Some cool function.', + 'parameters': { + 'type': 'OBJECT', + 'properties': { + 'schema1': { + 'type': 'STRING', + 'description': 'Some parameter.' + } + }, + 'required': ['schema1'] + } + }, + ], + }, + ]); + expect(request['toolConfig'], { + 'functionCallingConfig': { + 'mode': 'ANY', + 'allowedFunctionNames': ['someFunction'], + }, + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + }); + + group('generate content stream', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final results = {'First response', 'Second Response'}; + final response = await client.checkStreamRequest( + () async => model.generateContentStream([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/projects/123/locations/us-central1/publishers/google/models/some-model:streamGenerateContent', + ), + ); + expect(request, { + 'model': 'models/$defaultModelName', + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + }); + }, + responses: [ + for (final result in results) + { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [ + {'text': result}, + ], + }, + }, + ], + }, + ], + ); + expect( + response, + emitsInOrder([ + for (final result in results) + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content('model', [TextPart(result)]), + null, + null, + null, + null, + ), + ], null), + ), + ]), + ); + }); + + test('can override safety settings', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final responses = await client.checkStreamRequest( + () async => model.generateContentStream( + [Content.text(prompt)], + safetySettings: [ + SafetySetting( + HarmCategory.dangerousContent, + HarmBlockThreshold.high, + HarmBlockMethod.severity, + ), + ], + ), + verifyRequest: (_, request) { + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + 'method': 'SEVERITY', + }, + ]); + }, + responses: [arbitraryGenerateContentResponse], + ); + await responses.drain(); + }); + + test('can override generation config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final responses = await client.checkStreamRequest( + () async => model.generateContentStream([ + Content.text(prompt), + ], generationConfig: GenerationConfig(stopSequences: ['a'])), + verifyRequest: (_, request) { + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + }, + responses: [arbitraryGenerateContentResponse], + ); + await responses.drain(); + }); + }); + + group('count tokens', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final response = await client.checkRequest( + () => model.countTokens([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/projects/123/locations/us-central1/publishers/google/models/some-model:countTokens', + ), + ); + expect(request, { + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + }); + }, + response: {'totalTokens': 2}, + ); + expect(response, matchesCountTokensResponse(CountTokensResponse(2))); + }); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart b/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart new file mode 100644 index 000000000000..1414a5e3c50a --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/response_parsing_test.dart @@ -0,0 +1,875 @@ +// Copyright 2024 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 'dart:convert'; + +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:firebase_ai/src/api.dart'; +import 'package:firebase_ai/src/error.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/matchers.dart'; + +void main() { + group('throws errors for invalid GenerateContentResponse', () { + test('with empty content', () { + const response = ''' +{ + "candidates": [ + { + "content": {}, + "index": 0 + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + expect( + () => VertexSerialization().parseGenerateContentResponse(decoded), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Unhandled format for Content:'), + ), + ), + ); + }); + + test('with a blocked prompt', () { + const response = ''' +{ + "promptFeedback": { + "blockReason": "SAFETY", + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "HIGH" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [], + PromptFeedback(BlockReason.safety, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.high), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + expect( + () => generateContentResponse.text, + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Response was blocked due to safety'), + ), + ), + ); + }); + test('with service api not enabled', () { + const response = ''' +{ + "error": { + "code": 403, + "message": "Vertex AI in Firebase API has not been used in project test-project-id-1234 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/firebasevertexai.googleapis.com/overview?project=test-project-id-1234 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "status": "PERMISSION_DENIED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.Help", + "links": [ + { + "description": "Google developers console API activation", + "url": "https://console.developers.google.com/apis/api/firebasevertexai.googleapis.com/overview?project=test-project-id-1234" + } + ] + }, + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "SERVICE_DISABLED", + "domain": "googleapis.com", + "metadata": { + "service": "firebasevertexai.googleapis.com", + "consumer": "projects/test-project-id-1234" + } + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + expect( + () => VertexSerialization().parseGenerateContentResponse(decoded), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith( + 'The Vertex AI in Firebase SDK requires the Vertex AI in Firebase API'), + ), + ), + ); + }); + + test('with quota exceed', () { + const response = ''' +{ + "error": { + "code": 429, + "message": "Quota exceeded for quota metric 'Generate Content API requests per minute' and limit 'GenerateContent request limit per minute for a region' of service 'generativelanguage.googleapis.com' for consumer 'project_number:348715329010'.", + "status": "RESOURCE_EXHAUSTED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "RATE_LIMIT_EXCEEDED", + "domain": "googleapis.com", + "metadata": { + "service": "generativelanguage.googleapis.com", + "consumer": "projects/348715329010", + "quota_limit_value": "0", + "quota_limit": "GenerateContentRequestsPerMinutePerProjectPerRegion", + "quota_location": "us-east2", + "quota_metric": "generativelanguage.googleapis.com/generate_content_requests" + } + }, + { + "@type": "type.googleapis.com/google.rpc.Help", + "links": [ + { + "description": "Request a higher quota limit.", + "url": "https://cloud.google.com/docs/quota#requesting_higher_quota" + } + ] + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + expect( + () => VertexSerialization().parseGenerateContentResponse(decoded), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Quota exceeded for quota metric'), + ), + ), + ); + }); + }); + + group('parses successful GenerateContentResponse', () { + test('with a basic reply', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Mountain View, California, United States" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([ + TextPart('Mountain View, California, United States'), + ]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + null, + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('with a citation', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "placeholder" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "citationMetadata": { + "citationSources": [ + { + "startIndex": 574, + "endIndex": 705, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026 + }, + { + "uri": "https://example.com/", + "license": "" + }, + {} + ] + } + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([TextPart('placeholder')]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([ + Citation(574, 705, Uri.https('example.com'), ''), + Citation(899, 1026, Uri.https('example.com'), ''), + ]), + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('with a vertex formatted citation', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "placeholder" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "citationMetadata": { + "citations": [ + { + "startIndex": 574, + "endIndex": 705, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026 + }, + { + "uri": "https://example.com/", + "license": "" + }, + {} + ] + } + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([TextPart('placeholder')]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([ + Citation(574, 705, Uri.https('example.com'), ''), + Citation(899, 1026, Uri.https('example.com'), ''), + ]), + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('allows missing content', () async { + const response = ''' +{ + "candidates": [ + { + "finishReason": "SAFETY", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "LOW" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "MEDIUM" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content(null, []), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating( + HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([]), + FinishReason.safety, + null), + ], null), + ), + ); + }); + + test('response including usage metadata', () async { + const response = ''' +{ + "candidates": [{ + "content": { + "role": "model", + "parts": [{ + "text": "Here is a description of the image:" + }] + }, + "finishReason": "STOP" + }], + "usageMetadata": { + "promptTokenCount": 1837, + "candidatesTokenCount": 76, + "totalTokenCount": 1913, + "promptTokensDetails": [{ + "modality": "TEXT", + "tokenCount": 76 + }, { + "modality": "IMAGE", + "tokenCount": 1806 + }], + "candidatesTokensDetails": [{ + "modality": "TEXT", + "tokenCount": 76 + }] + } +} + '''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse.text, 'Here is a description of the image:'); + expect(generateContentResponse.usageMetadata?.totalTokenCount, 1913); + expect( + generateContentResponse + .usageMetadata?.promptTokensDetails?[1].modality, + ContentModality.image); + expect( + generateContentResponse + .usageMetadata?.promptTokensDetails?[1].tokenCount, + 1806); + expect( + generateContentResponse + .usageMetadata?.candidatesTokensDetails?.first.modality, + ContentModality.text); + expect( + generateContentResponse + .usageMetadata?.candidatesTokensDetails?.first.tokenCount, + 76); + }); + + test('countTokens with modality fields returned', () async { + const response = ''' +{ + "totalTokens": 1837, + "totalBillableCharacters": 117, + "promptTokensDetails": [{ + "modality": "IMAGE", + "tokenCount": 1806 + }, { + "modality": "TEXT", + "tokenCount": 31 + }] +} + '''; + final decoded = jsonDecode(response) as Object; + final countTokensResponse = + VertexSerialization().parseCountTokensResponse(decoded); + expect(countTokensResponse.totalTokens, 1837); + expect(countTokensResponse.promptTokensDetails?.first.modality, + ContentModality.image); + expect(countTokensResponse.promptTokensDetails?.first.tokenCount, 1806); + }); + + test('text getter joins content', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Initial text" + }, + { + "functionCall": {"name": "someFunction", "args": {}} + }, + { + "text": " And more text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); + expect(generateContentResponse.text, 'Initial text And more text'); + expect(generateContentResponse.candidates.single.text, + 'Initial text And more text'); + }); + }); + + group('parses and throws error responses', () { + test('for invalid API key', () async { + const response = ''' +{ + "error": { + "code": 400, + "message": "API key not valid. Please pass a valid API key.", + "status": "INVALID_ARGUMENT", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "API_KEY_INVALID", + "domain": "googleapis.com", + "metadata": { + "service": "generativelanguage.googleapis.com" + } + }, + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "Invalid API key: AIzv00G7VmUCUeC-5OglO3hcXM" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + 'API key not valid. Please pass a valid API key.', + ), + ); + expect(() => VertexSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => VertexSerialization().parseCountTokensResponse(decoded), + expectedThrow); + }); + + test('for unsupported user location', () async { + const response = r''' +{ + "error": { + "code": 400, + "message": "User location is not supported for the API use.", + "status": "FAILED_PRECONDITION", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::failed_precondition: User location is not supported for the API use. [google.rpc.error_details_ext] { message: \"User location is not supported for the API use.\" }" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + 'User location is not supported for the API use.', + ), + ); + expect(() => VertexSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => VertexSerialization().parseCountTokensResponse(decoded), + expectedThrow); + }); + + test('for general server errors', () async { + const response = r''' +{ + "error": { + "code": 404, + "message": "models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.", + "status": "NOT_FOUND", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::not_found: models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods. [google.rpc.error_details_ext] { message: \"models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.\" }" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith( + 'models/unknown is not found for API version v1, ' + 'or is not supported for GenerateContent.', + ), + ), + ); + expect(() => VertexSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => VertexSerialization().parseCountTokensResponse(decoded), + expectedThrow); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/schema_test.dart b/packages/firebase_ai/firebase_ai/test/schema_test.dart new file mode 100644 index 000000000000..e4b47be4be94 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/schema_test.dart @@ -0,0 +1,297 @@ +// Copyright 2024 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/schema.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Schema Tests', () { + // Test basic constructors and toJson() for primitive types + test('Schema.boolean', () { + final schema = Schema.boolean( + description: 'A boolean value', nullable: true, title: 'Is Active'); + expect(schema.type, SchemaType.boolean); + expect(schema.description, 'A boolean value'); + expect(schema.nullable, true); + expect(schema.title, 'Is Active'); + expect(schema.toJson(), { + 'type': 'BOOLEAN', + 'description': 'A boolean value', + 'nullable': true, + 'title': 'Is Active', + }); + }); + + test('Schema.integer', () { + final schema = Schema.integer( + format: 'int32', minimum: 0, maximum: 100, title: 'Count'); + expect(schema.type, SchemaType.integer); + expect(schema.format, 'int32'); + expect(schema.minimum, 0); + expect(schema.maximum, 100); + expect(schema.title, 'Count'); + expect(schema.toJson(), { + 'type': 'INTEGER', + 'format': 'int32', + 'minimum': 0.0, // Ensure double conversion + 'maximum': 100.0, // Ensure double conversion + 'title': 'Count', + }); + }); + + test('Schema.number', () { + final schema = Schema.number( + format: 'double', + nullable: false, + minimum: 0.5, + maximum: 99.5, + title: 'Percentage'); + expect(schema.type, SchemaType.number); + expect(schema.format, 'double'); + expect(schema.nullable, false); + expect(schema.minimum, 0.5); + expect(schema.maximum, 99.5); + expect(schema.title, 'Percentage'); + expect(schema.toJson(), { + 'type': 'NUMBER', + 'format': 'double', + 'nullable': false, + 'minimum': 0.5, + 'maximum': 99.5, + 'title': 'Percentage', + }); + }); + + test('Schema.string', () { + final schema = Schema.string(title: 'User Name'); + expect(schema.type, SchemaType.string); + expect(schema.title, 'User Name'); + expect(schema.toJson(), {'type': 'STRING', 'title': 'User Name'}); + }); + + test('Schema.enumString', () { + final schema = + Schema.enumString(enumValues: ['value1', 'value2'], title: 'Status'); + expect(schema.type, SchemaType.string); + expect(schema.format, 'enum'); + expect(schema.enumValues, ['value1', 'value2']); + expect(schema.title, 'Status'); + expect(schema.toJson(), { + 'type': 'STRING', + 'format': 'enum', + 'enum': ['value1', 'value2'], + 'title': 'Status', + }); + }); + + // Test constructors and toJson() for complex types + test('Schema.array', () { + final itemSchema = Schema.string(); + final schema = Schema.array( + items: itemSchema, minItems: 1, maxItems: 5, title: 'Tags'); + expect(schema.type, SchemaType.array); + expect(schema.items, itemSchema); + expect(schema.minItems, 1); + expect(schema.maxItems, 5); + expect(schema.title, 'Tags'); + expect(schema.toJson(), { + 'type': 'ARRAY', + 'items': {'type': 'STRING'}, + 'minItems': 1, + 'maxItems': 5, + 'title': 'Tags', + }); + }); + + test('Schema.object', () { + final properties = { + 'name': Schema.string(), + 'age': Schema.integer(), + 'city': Schema.string(description: 'City of residence'), + }; + final schema = Schema.object( + properties: properties, + optionalProperties: ['age'], + propertyOrdering: ['name', 'city', 'age'], + title: 'User Profile', + description: 'Represents a user profile', + ); + expect(schema.type, SchemaType.object); + expect(schema.properties, properties); + expect(schema.optionalProperties, ['age']); + expect(schema.propertyOrdering, ['name', 'city', 'age']); + expect(schema.title, 'User Profile'); + expect(schema.description, 'Represents a user profile'); + expect(schema.toJson(), { + 'type': 'OBJECT', + 'properties': { + 'name': {'type': 'STRING'}, + 'age': {'type': 'INTEGER'}, + 'city': {'type': 'STRING', 'description': 'City of residence'}, + }, + 'required': ['name', 'city'], + 'propertyOrdering': ['name', 'city', 'age'], + 'title': 'User Profile', + 'description': 'Represents a user profile', + }); + }); + + test('Schema.object with empty optionalProperties', () { + final properties = { + 'name': Schema.string(), + 'age': Schema.integer(), + }; + final schema = Schema.object( + properties: properties, + // No optionalProperties, so all are required + ); + expect(schema.type, SchemaType.object); + expect(schema.properties, properties); + expect(schema.toJson(), { + 'type': 'OBJECT', + 'properties': { + 'name': {'type': 'STRING'}, + 'age': {'type': 'INTEGER'}, + }, + 'required': ['name', 'age'], // All keys from properties + }); + }); + + test('Schema.object with all properties optional', () { + final properties = { + 'name': Schema.string(), + 'age': Schema.integer(), + }; + final schema = Schema.object( + properties: properties, + optionalProperties: ['name', 'age'], + ); + expect(schema.type, SchemaType.object); + expect(schema.properties, properties); + expect(schema.optionalProperties, ['name', 'age']); + expect(schema.toJson(), { + 'type': 'OBJECT', + 'properties': { + 'name': {'type': 'STRING'}, + 'age': {'type': 'INTEGER'}, + }, + 'required': [], // Empty list as all are optional + }); + }); + + // Test Schema.anyOf + test('Schema.anyOf', () { + final schema1 = Schema.string(description: 'A string value'); + final schema2 = Schema.integer(description: 'An integer value'); + final schema = Schema.anyOf(schemas: [schema1, schema2]); + + // The type field is SchemaType.anyOf internally for dispatching toJson + // but it should not be present in the final JSON for `anyOf`. + expect(schema.type, SchemaType.anyOf); + expect(schema.anyOf, [schema1, schema2]); + expect(schema.toJson(), { + 'anyOf': [ + {'type': 'STRING', 'description': 'A string value'}, + {'type': 'INTEGER', 'description': 'An integer value'}, + ], + }); + }); + + test('Schema.anyOf with complex types', () { + final userSchema = Schema.object(properties: { + 'id': Schema.integer(), + 'username': Schema.string(), + }, optionalProperties: [ + 'username' + ]); + final errorSchema = Schema.object(properties: { + 'errorCode': Schema.integer(), + 'errorMessage': Schema.string(), + }); + final schema = Schema.anyOf(schemas: [userSchema, errorSchema]); + + expect(schema.type, SchemaType.anyOf); + expect(schema.anyOf?.length, 2); + expect(schema.toJson(), { + 'anyOf': [ + { + 'type': 'OBJECT', + 'properties': { + 'id': {'type': 'INTEGER'}, + 'username': {'type': 'STRING'}, + }, + 'required': ['id'], + }, + { + 'type': 'OBJECT', + 'properties': { + 'errorCode': {'type': 'INTEGER'}, + 'errorMessage': {'type': 'STRING'}, + }, + 'required': ['errorCode', 'errorMessage'], + }, + ], + }); + }); + + // Test SchemaType.toJson() + test('SchemaType.toJson', () { + expect(SchemaType.string.toJson(), 'STRING'); + expect(SchemaType.number.toJson(), 'NUMBER'); + expect(SchemaType.integer.toJson(), 'INTEGER'); + expect(SchemaType.boolean.toJson(), 'BOOLEAN'); + expect(SchemaType.array.toJson(), 'ARRAY'); + expect(SchemaType.object.toJson(), 'OBJECT'); + expect(SchemaType.anyOf.toJson(), + 'null'); // As per implementation, 'null' string for anyOf + }); + + // Test edge cases + test('Schema.object with no properties', () { + final schema = Schema.object(properties: {}); + expect(schema.type, SchemaType.object); + expect(schema.properties, {}); + expect(schema.toJson(), { + 'type': 'OBJECT', + 'properties': {}, + 'required': [], + }); + }); + + test('Schema.array with no items (should not happen with constructor)', () { + // This is more of a theoretical test as the constructor requires `items`. + // We construct it manually to test `toJson` robustness. + final schema = Schema(SchemaType.array); + expect(schema.type, SchemaType.array); + expect(schema.toJson(), { + 'type': 'ARRAY', + // 'items' field should be absent if items is null + }); + }); + + test('Schema with all optional fields null', () { + final schema = Schema(SchemaType.string); // Only type is provided + expect(schema.type, SchemaType.string); + expect(schema.format, isNull); + expect(schema.description, isNull); + expect(schema.nullable, isNull); + expect(schema.enumValues, isNull); + expect(schema.items, isNull); + expect(schema.properties, isNull); + expect(schema.optionalProperties, isNull); + expect(schema.anyOf, isNull); + expect(schema.toJson(), {'type': 'STRING'}); + }); + }); +} diff --git a/packages/firebase_ai/firebase_ai/test/utils/matchers.dart b/packages/firebase_ai/firebase_ai/test/utils/matchers.dart new file mode 100644 index 000000000000..39c23188b677 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/utils/matchers.dart @@ -0,0 +1,100 @@ +// Copyright 2024 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/firebase_ai.dart'; +import 'package:http/http.dart' as http; +import 'package:matcher/matcher.dart'; + +Matcher matchesPart(Part part) => switch (part) { + TextPart(text: final text) => + isA().having((p) => p.text, 'text', text), + InlineDataPart(mimeType: final mimeType, bytes: final bytes) => + isA() + .having((p) => p.mimeType, 'mimeType', mimeType) + .having((p) => p.bytes, 'bytes', bytes), + FileData(mimeType: final mimeType, fileUri: final fileUri) => + isA() + .having((p) => p.mimeType, 'mimeType', mimeType) + .having((p) => p.fileUri, 'fileUri', fileUri), + FunctionCall(name: final name, args: final args) => isA() + .having((p) => p.name, 'name', name) + .having((p) => p.args, 'args', args), + FunctionResponse(name: final name, response: final response) => + isA() + .having((p) => p.name, 'name', name) + .having((p) => p.response, 'args', response), + }; + +Matcher matchesContent(Content content) => isA() + .having((c) => c.role, 'role', content.role) + .having((c) => c.parts, 'parts', content.parts.map(matchesPart).toList()); + +Matcher matchesCandidate(Candidate candidate) => isA().having( + (c) => c.content, + 'content', + matchesContent(candidate.content), + ); + +Matcher matchesGenerateContentResponse(GenerateContentResponse response) => + isA() + .having( + (r) => r.candidates, + 'candidates', + response.candidates.map(matchesCandidate).toList(), + ) + .having( + (r) => r.promptFeedback, + 'promptFeedback', + response.promptFeedback == null + ? isNull + : matchesPromptFeedback(response.promptFeedback!), + ); + +Matcher matchesPromptFeedback( + PromptFeedback promptFeedback, +) => + isA() + .having((p) => p.blockReason, 'blockReason', promptFeedback.blockReason) + .having( + (p) => p.blockReasonMessage, + 'blockReasonMessage', + promptFeedback.blockReasonMessage, + ) + .having( + (p) => p.safetyRatings, + 'safetyRatings', + unorderedMatches( + promptFeedback.safetyRatings.map(matchesSafetyRating)), + ); + +Matcher matchesSafetyRating(SafetyRating safetyRating) => isA() + .having((s) => s.category, 'category', safetyRating.category) + .having((s) => s.probability, 'probability', safetyRating.probability); + +Matcher matchesCountTokensResponse(CountTokensResponse response) => + isA().having( + (r) => r.totalTokens, + 'totalTokens', + response.totalTokens, + ); + +Matcher matchesRequest(http.Request request) => isA() + .having((r) => r.headers, 'headers', request.headers) + .having((r) => r.method, 'method', request.method) + .having((r) => r.bodyBytes, 'bodyBytes', request.bodyBytes) + .having((r) => r.url, 'url', request.url); + +Matcher matchesBaseRequest(http.BaseRequest request) => isA() + .having((r) => r.headers, 'headers', request.headers) + .having((r) => r.method, 'method', request.method) + .having((r) => r.url, 'url', request.url); diff --git a/packages/firebase_ai/firebase_ai/test/utils/stub_client.dart b/packages/firebase_ai/firebase_ai/test/utils/stub_client.dart new file mode 100644 index 000000000000..fa0704316574 --- /dev/null +++ b/packages/firebase_ai/firebase_ai/test/utils/stub_client.dart @@ -0,0 +1,93 @@ +// Copyright 2024 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 'dart:collection'; + +import 'package:firebase_ai/src/client.dart'; + +class ClientController { + final _client = _ControlledClient(); + ApiClient get client => _client; + + /// Run [body] and return [response] for a single call to + /// [ApiClient.streamRequest]. + /// + /// Check expectations for the request URI and JSON payload with the + /// [verifyRequest] callback. + Future checkRequest( + Future Function() body, { + required Map response, + void Function(Uri, Map)? verifyRequest, + }) async { + _client._requestExpectations.addLast(verifyRequest); + _client._responses.addLast([response]); + final result = await body(); + assert(_client._responses.isEmpty); + return result; + } + + /// Run [body] and return [responses] for a single call to + /// [ApiClient.streamRequest]. + /// + /// Check expectations for the request URI and JSON payload with the + /// [verifyRequest] callback. + Future checkStreamRequest( + Future Function() body, { + required Iterable> responses, + void Function(Uri, Map)? verifyRequest, + }) async { + _client._requestExpectations.addLast(verifyRequest); + _client._responses.addLast(responses.toList()); + final result = await body(); + assert(_client._responses.isEmpty); + return result; + } +} + +final class _ControlledClient implements ApiClient { + final _requestExpectations = + Queue)?>(); + final _responses = Queue>>(); + + @override + Future> makeRequest( + Uri uri, + Map body, + ) async { + _requestExpectations.removeFirst()?.call(uri, body); + return _responses.removeFirst().single; + } + + @override + Stream> streamRequest( + Uri uri, + Map body, + ) { + _requestExpectations.removeFirst()?.call(uri, body); + return Stream.fromIterable(_responses.removeFirst()); + } +} + +const Map arbitraryGenerateContentResponse = { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [ + {'text': 'Some Response'}, + ], + }, + }, + ], +}; diff --git a/packages/firebase_vertexai/analysis_options.yaml b/packages/firebase_vertexai/analysis_options.yaml index 5004d8c56d8c..ef9d047bb7f0 100644 --- a/packages/firebase_vertexai/analysis_options.yaml +++ b/packages/firebase_vertexai/analysis_options.yaml @@ -10,6 +10,7 @@ analyzer: # We explicitly enabled even conflicting rules and are fixing the conflict # in this file included_file_warning: ignore + deprecated_member_use_from_same_package: ignore linter: rules: diff --git a/packages/firebase_vertexai/firebase_vertexai/example/.gitignore b/packages/firebase_vertexai/firebase_vertexai/example/.gitignore index 0498b592dfa0..53bed76d8faa 100644 --- a/packages/firebase_vertexai/firebase_vertexai/example/.gitignore +++ b/packages/firebase_vertexai/firebase_vertexai/example/.gitignore @@ -48,3 +48,4 @@ app.*.map.json firebase_options.dart google-services.json GoogleService-Info.plist +firebase.json diff --git a/packages/firebase_vertexai/firebase_vertexai/example/android/app/build.gradle b/packages/firebase_vertexai/firebase_vertexai/example/android/app/build.gradle index b5a45dbd1ff0..1c814271cc8e 100644 --- a/packages/firebase_vertexai/firebase_vertexai/example/android/app/build.gradle +++ b/packages/firebase_vertexai/firebase_vertexai/example/android/app/build.gradle @@ -26,12 +26,12 @@ if (flutterVersionName == null) { } android { - namespace "com.example.example" + namespace "io.flutter.plugins.firebase.vertexai.example" compileSdk 35 defaultConfig { - applicationId "com.example.example" + applicationId "io.flutter.plugins.firebase.vertexai.example" minSdk 23 targetSdk 33 versionCode flutterVersionCode.toInteger() diff --git a/packages/firebase_vertexai/firebase_vertexai/example/android/settings.gradle b/packages/firebase_vertexai/firebase_vertexai/example/android/settings.gradle index 9151bc043341..40cbd22bb13b 100644 --- a/packages/firebase_vertexai/firebase_vertexai/example/android/settings.gradle +++ b/packages/firebase_vertexai/firebase_vertexai/example/android/settings.gradle @@ -19,6 +19,9 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version "7.3.0" 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 "1.9.22" apply false } diff --git a/packages/firebase_vertexai/firebase_vertexai/example/lib/main.dart b/packages/firebase_vertexai/firebase_vertexai/example/lib/main.dart index 7928b1aa1d87..4cef5dd9643d 100644 --- a/packages/firebase_vertexai/firebase_vertexai/example/lib/main.dart +++ b/packages/firebase_vertexai/firebase_vertexai/example/lib/main.dart @@ -14,6 +14,7 @@ import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_auth/firebase_auth.dart'; +import 'package:firebase_ai/firebase_ai.dart'; import 'package:firebase_vertexai/firebase_vertexai.dart'; import 'package:flutter/material.dart'; @@ -38,23 +39,82 @@ void main() async { // await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(); await FirebaseAuth.instance.signInAnonymously(); + runApp(const GenerativeAISample()); +} - var vertexInstance = - FirebaseVertexAI.instanceFor(auth: FirebaseAuth.instance); - final model = vertexInstance.generativeModel(model: 'gemini-2.0-flash'); +class GenerativeAISample extends StatefulWidget { + const GenerativeAISample({super.key}); - runApp(GenerativeAISample(model: model)); + @override + State createState() => _GenerativeAISampleState(); } -class GenerativeAISample extends StatelessWidget { - final GenerativeModel model; +class _GenerativeAISampleState extends State { + bool _useVertexBackend = false; + late GenerativeModel _currentModel; + late ImagenModel _currentImagenModel; + int _currentBottomNavIndex = 0; + + @override + void initState() { + super.initState(); + + _initializeModel(_useVertexBackend); + } + + void _initializeModel(bool useVertexBackend) { + var generationConfig = ImagenGenerationConfig( + negativePrompt: 'frog', + numberOfImages: 1, + aspectRatio: ImagenAspectRatio.square1x1, + imageFormat: ImagenFormat.jpeg(compressionQuality: 75), + ); + if (useVertexBackend) { + final vertexInstance = + // ignore: deprecated_member_use + FirebaseVertexAI.instanceFor(auth: FirebaseAuth.instance); + _currentModel = vertexInstance.generativeModel(model: 'gemini-1.5-flash'); + _currentImagenModel = vertexInstance.imagenModel( + model: 'imagen-3.0-generate-001', + generationConfig: generationConfig, + safetySettings: ImagenSafetySettings( + ImagenSafetyFilterLevel.blockLowAndAbove, + ImagenPersonFilterLevel.allowAdult, + ), + ); + } else { + final googleAI = FirebaseAI.googleAI(auth: FirebaseAuth.instance); + _currentModel = googleAI.generativeModel(model: 'gemini-2.0-flash'); + _currentImagenModel = googleAI.imagenModel( + model: 'imagen-3.0-generate-001', + generationConfig: generationConfig, + safetySettings: ImagenSafetySettings( + ImagenSafetyFilterLevel.blockLowAndAbove, + ImagenPersonFilterLevel.allowAdult, + ), + ); + } + } - const GenerativeAISample({super.key, required this.model}); + void _toggleBackend(bool value) { + setState(() { + _useVertexBackend = value; + }); + _initializeModel(_useVertexBackend); + } + + void _onBottomNavTapped(int index) { + setState(() { + _currentBottomNavIndex = index; + }); + } @override Widget build(BuildContext context) { return MaterialApp( - title: 'Flutter + Vertex AI', + title: 'Flutter + ${_useVertexBackend ? 'Vertex AI' : 'Google AI'}', + debugShowCheckedModeBanner: false, + themeMode: ThemeMode.dark, theme: ThemeData( colorScheme: ColorScheme.fromSeed( brightness: Brightness.dark, @@ -62,137 +122,204 @@ class GenerativeAISample extends StatelessWidget { ), useMaterial3: true, ), - home: HomeScreen(model: model), + home: HomeScreen( + key: ValueKey( + '${_useVertexBackend}_${_currentModel.hashCode}', + ), + model: _currentModel, + imagenModel: _currentImagenModel, + useVertexBackend: _useVertexBackend, + onBackendChanged: _toggleBackend, + selectedIndex: _currentBottomNavIndex, + onSelectedIndexChanged: _onBottomNavTapped, + ), ); } } class HomeScreen extends StatefulWidget { final GenerativeModel model; - const HomeScreen({super.key, required this.model}); + final ImagenModel imagenModel; + final bool useVertexBackend; + final ValueChanged onBackendChanged; + final int selectedIndex; + final ValueChanged onSelectedIndexChanged; + + const HomeScreen({ + super.key, + required this.model, + required this.imagenModel, + required this.useVertexBackend, + required this.onBackendChanged, + required this.selectedIndex, + required this.onSelectedIndexChanged, + }); @override State createState() => _HomeScreenState(); } class _HomeScreenState extends State { - int _selectedIndex = 0; - - List get _pages => [ - // Build _pages dynamically - ChatPage(title: 'Chat', model: widget.model), - AudioPage(title: 'Audio', model: widget.model), - TokenCountPage(title: 'Token Count', model: widget.model), - const FunctionCallingPage( - title: 'Function Calling', - ), // function calling will initial its own model - ImagePromptPage(title: 'Image Prompt', model: widget.model), - ImagenPage(title: 'Imagen Model', model: widget.model), - SchemaPromptPage(title: 'Schema Prompt', model: widget.model), - DocumentPage(title: 'Document Prompt', model: widget.model), - VideoPage(title: 'Video Prompt', model: widget.model), - BidiPage(title: 'Bidi Stream', model: widget.model), - ]; - void _onItemTapped(int index) { - setState(() { - _selectedIndex = index; - }); + widget.onSelectedIndexChanged(index); + } + +// Method to build the selected page on demand + Widget _buildSelectedPage( + int index, + GenerativeModel currentModel, + ImagenModel currentImagenModel, + bool useVertexBackend, + ) { + switch (index) { + case 0: + return ChatPage(title: 'Chat', model: currentModel); + case 1: + return AudioPage(title: 'Audio', model: currentModel); + case 2: + return TokenCountPage(title: 'Token Count', model: currentModel); + case 3: + // FunctionCallingPage initializes its own model as per original design + return FunctionCallingPage( + title: 'Function Calling', + useVertexBackend: useVertexBackend, + ); + case 4: + return ImagePromptPage(title: 'Image Prompt', model: currentModel); + case 5: + return ImagenPage(title: 'Imagen Model', model: currentImagenModel); + case 6: + return SchemaPromptPage(title: 'Schema Prompt', model: currentModel); + case 7: + return DocumentPage(title: 'Document Prompt', model: currentModel); + case 8: + return VideoPage(title: 'Video Prompt', model: currentModel); + case 9: + return BidiPage(title: 'Bidi Stream', model: currentModel); + default: + // Fallback to the first page in case of an unexpected index + return ChatPage(title: 'Chat', model: currentModel); + } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Flutter + Vertex AI'), + title: Text( + 'Flutter + ${widget.useVertexBackend ? 'Vertex AI' : 'Google AI'}', + ), + actions: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Google AI', + style: TextStyle( + fontSize: 12, + color: widget.useVertexBackend + ? Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7) + : Theme.of(context).colorScheme.primary, + ), + ), + Switch( + value: widget.useVertexBackend, + onChanged: widget.onBackendChanged, + activeTrackColor: Colors.green.withValues(alpha: 0.5), + inactiveTrackColor: Colors.blueGrey.withValues(alpha: 0.5), + activeColor: Colors.green, + inactiveThumbColor: Colors.blueGrey, + ), + Text( + 'Vertex AI', + style: TextStyle( + fontSize: 12, + color: widget.useVertexBackend + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.7), + ), + ), + ], + ), + ), + ], ), body: Center( - child: _pages.elementAt(_selectedIndex), + child: _buildSelectedPage( + widget.selectedIndex, + widget.model, + widget.imagenModel, + widget.useVertexBackend, + ), ), bottomNavigationBar: BottomNavigationBar( - items: [ + type: BottomNavigationBarType.fixed, + selectedFontSize: 10, + unselectedFontSize: 9, + selectedItemColor: Theme.of(context).colorScheme.primary, + unselectedItemColor: + Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + items: const [ BottomNavigationBarItem( - icon: Icon( - Icons.chat, - color: Theme.of(context).colorScheme.primary, - ), + icon: Icon(Icons.chat), label: 'Chat', tooltip: 'Chat', ), BottomNavigationBarItem( - icon: Icon( - Icons.mic, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Audio Prompt', + icon: Icon(Icons.mic), + label: 'Audio', tooltip: 'Audio Prompt', ), BottomNavigationBarItem( - icon: Icon( - Icons.numbers, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Token Count', + icon: Icon(Icons.numbers), + label: 'Tokens', tooltip: 'Token Count', ), BottomNavigationBarItem( - icon: Icon( - Icons.functions, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Function Calling', + icon: Icon(Icons.functions), + label: 'Functions', tooltip: 'Function Calling', ), BottomNavigationBarItem( - icon: Icon( - Icons.image, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Image Prompt', + icon: Icon(Icons.image), + label: 'Image', tooltip: 'Image Prompt', ), BottomNavigationBarItem( - icon: Icon( - Icons.image_search, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Imagen Model', + icon: Icon(Icons.image_search), + label: 'Imagen', tooltip: 'Imagen Model', ), BottomNavigationBarItem( - icon: Icon( - Icons.schema, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Schema Prompt', + icon: Icon(Icons.schema), + label: 'Schema', tooltip: 'Schema Prompt', ), BottomNavigationBarItem( - icon: Icon( - Icons.edit_document, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Document Prompt', + icon: Icon(Icons.edit_document), + label: 'Document', tooltip: 'Document Prompt', ), BottomNavigationBarItem( - icon: Icon( - Icons.video_collection, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Video Prompt', + icon: Icon(Icons.video_collection), + label: 'Video', tooltip: 'Video Prompt', ), BottomNavigationBarItem( - icon: Icon( - Icons.stream, - color: Theme.of(context).colorScheme.primary, - ), - label: 'Bidi Stream', + icon: Icon(Icons.stream), + label: 'Bidi', tooltip: 'Bidi Stream', ), ], - currentIndex: _selectedIndex, + currentIndex: widget.selectedIndex, onTap: _onItemTapped, ), ); diff --git a/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/bidi_page.dart b/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/bidi_page.dart index 31b693c96208..4cd509d1257f 100644 --- a/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/bidi_page.dart +++ b/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/bidi_page.dart @@ -65,6 +65,7 @@ class _BidiPageState extends State { ], ); + // ignore: deprecated_member_use _liveModel = FirebaseVertexAI.instance.liveGenerativeModel( model: 'gemini-2.0-flash-exp', liveGenerationConfig: config, diff --git a/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/function_calling_page.dart b/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/function_calling_page.dart index 130afff5ce92..fe4f976c41ea 100644 --- a/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/function_calling_page.dart +++ b/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/function_calling_page.dart @@ -13,14 +13,21 @@ // limitations under the License. import 'package:flutter/material.dart'; +import 'package:firebase_ai/firebase_ai.dart'; import 'package:firebase_vertexai/firebase_vertexai.dart'; import 'package:firebase_auth/firebase_auth.dart'; + import '../widgets/message_widget.dart'; class FunctionCallingPage extends StatefulWidget { - const FunctionCallingPage({super.key, required this.title}); + const FunctionCallingPage({ + super.key, + required this.title, + required this.useVertexBackend, + }); final String title; + final bool useVertexBackend; @override State createState() => _FunctionCallingPageState(); @@ -41,14 +48,24 @@ class _FunctionCallingPageState extends State { @override void initState() { super.initState(); - var vertex_instance = - FirebaseVertexAI.instanceFor(auth: FirebaseAuth.instance); - _functionCallModel = vertex_instance.generativeModel( - model: 'gemini-1.5-flash', - tools: [ - Tool.functionDeclarations([fetchWeatherTool]), - ], - ); + if (widget.useVertexBackend) { + // ignore: deprecated_member_use + var vertexAI = FirebaseVertexAI.instanceFor(auth: FirebaseAuth.instance); + _functionCallModel = vertexAI.generativeModel( + model: 'gemini-2.0-flash', + tools: [ + Tool.functionDeclarations([fetchWeatherTool]), + ], + ); + } else { + var googleAI = FirebaseAI.googleAI(auth: FirebaseAuth.instance); + _functionCallModel = googleAI.generativeModel( + model: 'gemini-2.0-flash', + tools: [ + Tool.functionDeclarations([fetchWeatherTool]), + ], + ); + } } // This is a hypothetical API to return a fake weather data collection for @@ -146,7 +163,7 @@ class _FunctionCallingPageState extends State { _loading = true; }); final functionCallChat = _functionCallModel.startChat(); - const prompt = 'What is the weather like in Boston on 10/02 this year?'; + const prompt = 'What is the weather like in Boston on 10/02 in year 2024?'; // Send the message to the generative model. var response = await functionCallChat.sendMessage( diff --git a/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/imagen_page.dart b/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/imagen_page.dart index bb08a4b5533a..0ab750b13fef 100644 --- a/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/imagen_page.dart +++ b/packages/firebase_vertexai/firebase_vertexai/example/lib/pages/imagen_page.dart @@ -25,7 +25,7 @@ class ImagenPage extends StatefulWidget { }); final String title; - final GenerativeModel model; + final ImagenModel model; @override State createState() => _ImagenPageState(); @@ -37,26 +37,6 @@ class _ImagenPageState extends State { final FocusNode _textFieldFocus = FocusNode(); final List _generatedContent = []; bool _loading = false; - late final ImagenModel _imagenModel; - - @override - void initState() { - super.initState(); - var generationConfig = ImagenGenerationConfig( - negativePrompt: 'frog', - numberOfImages: 1, - aspectRatio: ImagenAspectRatio.square1x1, - imageFormat: ImagenFormat.jpeg(compressionQuality: 75), - ); - _imagenModel = FirebaseVertexAI.instance.imagenModel( - model: 'imagen-3.0-generate-001', - generationConfig: generationConfig, - safetySettings: ImagenSafetySettings( - ImagenSafetyFilterLevel.blockLowAndAbove, - ImagenPersonFilterLevel.allowAdult, - ), - ); - } void _scrollDown() { WidgetsBinding.instance.addPostFrameCallback( @@ -153,22 +133,27 @@ class _ImagenPageState extends State { _loading = true; }); - var response = await _imagenModel.generateImages(prompt); + try { + var response = await widget.model.generateImages(prompt); - if (response.images.isNotEmpty) { - var imagenImage = response.images[0]; + if (response.images.isNotEmpty) { + var imagenImage = response.images[0]; - _generatedContent.add( - MessageData( - image: Image.memory(imagenImage.bytesBase64Encoded), - text: prompt, - fromUser: false, - ), - ); - } else { - // Handle the case where no images were generated - _showError('Error: No images were generated.'); + _generatedContent.add( + MessageData( + image: Image.memory(imagenImage.bytesBase64Encoded), + text: prompt, + fromUser: false, + ), + ); + } else { + // Handle the case where no images were generated + _showError('Error: No images were generated.'); + } + } catch (e) { + _showError(e.toString()); } + setState(() { _loading = false; _scrollDown(); @@ -181,7 +166,7 @@ class _ImagenPageState extends State { // }); // var gcsUrl = 'gs://vertex-ai-example-ef5a2.appspot.com/imagen'; - // var response = await _imagenModel.generateImagesGCS(prompt, gcsUrl); + // var response = await widget.model.generateImagesGCS(prompt, gcsUrl); // if (response.images.isNotEmpty) { // var imagenImage = response.images[0]; diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/firebase_vertexai.dart b/packages/firebase_vertexai/firebase_vertexai/lib/firebase_vertexai.dart index de96a38e8302..d0580c4928e5 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/firebase_vertexai.dart +++ b/packages/firebase_vertexai/firebase_vertexai/lib/firebase_vertexai.dart @@ -12,7 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -export 'src/api.dart' +import 'package:firebase_ai/firebase_ai.dart' + show FirebaseAIException, FirebaseAISdkException; + +export 'package:firebase_ai/firebase_ai.dart' show BlockReason, Candidate, @@ -30,53 +33,52 @@ export 'src/api.dart' ResponseModalities, SafetyRating, SafetySetting, - // TODO(cynthiajiang) remove in next breaking change. - TaskType, - UsageMetadata; -export 'src/base_model.dart' - show GenerativeModel, ImagenModel, LiveGenerativeModel; -export 'src/chat.dart' show ChatSession, StartChatExtension; -export 'src/content.dart' - show + UsageMetadata, + GenerativeModel, + ImagenModel, + LiveGenerativeModel, + ChatSession, + StartChatExtension, Content, InlineDataPart, FileData, FunctionCall, FunctionResponse, Part, - TextPart; -export 'src/error.dart' - show - VertexAIException, - VertexAISdkException, + TextPart, InvalidApiKey, ServerException, - UnsupportedUserLocation; -export 'src/firebase_vertexai.dart' show FirebaseVertexAI; -export 'src/function_calling.dart' - show + UnsupportedUserLocation, FunctionCallingConfig, FunctionCallingMode, FunctionDeclaration, Tool, - ToolConfig; -export 'src/imagen_api.dart' - show + ToolConfig, ImagenSafetySettings, ImagenFormat, ImagenSafetyFilterLevel, ImagenPersonFilterLevel, ImagenGenerationConfig, - ImagenAspectRatio; -export 'src/imagen_content.dart' show ImagenInlineImage; -export 'src/live_api.dart' - show + ImagenAspectRatio, + ImagenInlineImage, LiveGenerationConfig, SpeechConfig, LiveServerMessage, LiveServerContent, LiveServerToolCall, LiveServerToolCallCancellation, - LiveServerResponse; -export 'src/live_session.dart' show LiveSession; -export 'src/schema.dart' show Schema, SchemaType; + LiveServerResponse, + LiveSession, + Schema, + SchemaType; +export 'src/firebase_vertexai.dart' show FirebaseVertexAI; + +/// Exception thrown when generating content fails. +typedef VertexAIException = FirebaseAIException; + +/// Exception indicating a stale package version or implementation bug. +/// +/// This exception indicates a likely problem with the SDK implementation such +/// as an inability to parse a new response format. Resolution paths may include +/// updating to a new version of the SDK, or filing an issue. +typedef VertexAISdkException = FirebaseAISdkException; diff --git a/packages/firebase_vertexai/firebase_vertexai/lib/src/firebase_vertexai.dart b/packages/firebase_vertexai/firebase_vertexai/lib/src/firebase_vertexai.dart index 80f2c68a026c..f7b3952e649c 100644 --- a/packages/firebase_vertexai/firebase_vertexai/lib/src/firebase_vertexai.dart +++ b/packages/firebase_vertexai/firebase_vertexai/lib/src/firebase_vertexai.dart @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:firebase_ai/firebase_ai.dart'; +// ignore: implementation_imports +import 'package:firebase_ai/src/base_model.dart' + show createGenerativeModel, createLiveGenerativeModel, createImagenModel; import 'package:firebase_app_check/firebase_app_check.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -19,16 +23,23 @@ import 'package:firebase_core_platform_interface/firebase_core_platform_interfac show FirebasePluginPlatform; import 'package:meta/meta.dart'; -import '../firebase_vertexai.dart'; -import 'base_model.dart'; - const _defaultLocation = 'us-central1'; /// The entrypoint for [FirebaseVertexAI]. +@Deprecated( + '`FirebaseVertexAI` library and `firebase_vertexai` package have been renamed ' + 'and replaced by the new Firebase AI SDK: `FirebaseAI` in `firebase_ai` package. ' + 'See details in the [migration guide](https://firebase.google.com/docs/vertex-ai/migrate-to-latest-sdk).', +) class FirebaseVertexAI extends FirebasePluginPlatform { FirebaseVertexAI._( - {required this.app, required this.location, this.appCheck, this.auth}) - : super(app.name, 'plugins.flutter.io/firebase_vertexai'); + {required this.app, + required this.location, + required bool useVertexBackend, + this.appCheck, + this.auth}) + : _useVertexBackend = useVertexBackend, + super(app.name, 'plugins.flutter.io/firebase_vertexai'); /// The [FirebaseApp] for this current [FirebaseVertexAI] instance. FirebaseApp app; @@ -43,6 +54,8 @@ class FirebaseVertexAI extends FirebasePluginPlatform { /// The service location for this [FirebaseVertexAI] instance. String location; + final bool _useVertexBackend; + static final Map _cachedInstances = {}; /// Returns an instance using the default [FirebaseApp]. @@ -61,18 +74,36 @@ class FirebaseVertexAI extends FirebasePluginPlatform { FirebaseAppCheck? appCheck, FirebaseAuth? auth, String? location, + }) => + _vertexAI(app: app, appCheck: appCheck, auth: auth, location: location); + + /// Returns an instance using a specified [FirebaseApp]. + /// + /// If [app] is not provided, the default Firebase app will be used. + /// If pass in [appCheck], request session will get protected from abusing. + static FirebaseVertexAI _vertexAI({ + FirebaseApp? app, + FirebaseAppCheck? appCheck, + FirebaseAuth? auth, + String? location, }) { app ??= Firebase.app(); + var instanceKey = '${app.name}::vertexai'; - if (_cachedInstances.containsKey(app.name)) { - return _cachedInstances[app.name]!; + if (_cachedInstances.containsKey(instanceKey)) { + return _cachedInstances[instanceKey]!; } location ??= _defaultLocation; FirebaseVertexAI newInstance = FirebaseVertexAI._( - app: app, location: location, appCheck: appCheck, auth: auth); - _cachedInstances[app.name] = newInstance; + app: app, + location: location, + appCheck: appCheck, + auth: auth, + useVertexBackend: true, + ); + _cachedInstances[instanceKey] = newInstance; return newInstance; } @@ -100,6 +131,7 @@ class FirebaseVertexAI extends FirebasePluginPlatform { model: model, app: app, appCheck: appCheck, + useVertexBackend: _useVertexBackend, auth: auth, location: location, safetySettings: safetySettings, @@ -123,6 +155,7 @@ class FirebaseVertexAI extends FirebasePluginPlatform { app: app, location: location, model: model, + useVertexBackend: _useVertexBackend, generationConfig: generationConfig, safetySettings: safetySettings, appCheck: appCheck, diff --git a/packages/firebase_vertexai/firebase_vertexai/pubspec.yaml b/packages/firebase_vertexai/firebase_vertexai/pubspec.yaml index ef02f0492533..dd2fa0a4239b 100644 --- a/packages/firebase_vertexai/firebase_vertexai/pubspec.yaml +++ b/packages/firebase_vertexai/firebase_vertexai/pubspec.yaml @@ -20,6 +20,7 @@ environment: flutter: ">=3.16.0" dependencies: + firebase_ai: ^0.1.0 firebase_app_check: ^0.3.2+5 firebase_auth: ^5.5.3 firebase_core: ^3.13.0 diff --git a/packages/firebase_vertexai/firebase_vertexai/test/api_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/api_test.dart index 0b6359f9a6ef..e00b8090325a 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/api_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/api_test.dart @@ -12,10 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:firebase_vertexai/src/api.dart'; -import 'package:firebase_vertexai/src/content.dart'; -import 'package:firebase_vertexai/src/error.dart'; -import 'package:firebase_vertexai/src/schema.dart'; +import 'package:firebase_ai/firebase_ai.dart'; +import 'package:firebase_ai/src/api.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -68,39 +66,44 @@ void main() { }); test( - 'throws VertexAIException if prompt was blocked without message or reason', + 'throws FirebaseAIException if prompt was blocked without message or reason', () { final feedback = PromptFeedback(BlockReason.safety, null, []); final response = GenerateContentResponse([], feedback); expect( () => response.text, - throwsA(isA().having((e) => e.message, 'message', - 'Response was blocked due to safety'))); + throwsA(isA().having((e) => e.message, + 'message', 'Response was blocked due to safety'))); }); test( - 'throws VertexAIException if prompt was blocked with reason and message', + 'throws FirebaseAIException if prompt was blocked with reason and message', () { final feedback = PromptFeedback(BlockReason.other, 'Custom block message', []); final response = GenerateContentResponse([], feedback); expect( () => response.text, - throwsA(isA().having((e) => e.message, 'message', + throwsA(isA().having( + (e) => e.message, + 'message', 'Response was blocked due to other: Custom block message'))); }); - test('throws VertexAIException if first candidate finished due to safety', + test( + 'throws FirebaseAIException if first candidate finished due to safety', () { final response = GenerateContentResponse([candidateFinishedSafety], null); expect( () => response.text, - throwsA(isA().having((e) => e.message, 'message', + throwsA(isA().having( + (e) => e.message, + 'message', 'Candidate was blocked due to safety: Safety concern'))); }); test( - 'throws VertexAIException if first candidate finished due to safety without message', + 'throws FirebaseAIException if first candidate finished due to safety without message', () { final candidateFinishedSafetyNoMsg = Candidate(textContent, null, null, FinishReason.safety, ''); @@ -108,18 +111,20 @@ void main() { GenerateContentResponse([candidateFinishedSafetyNoMsg], null); expect( () => response.text, - throwsA(isA().having((e) => e.message, 'message', - 'Candidate was blocked due to safety'))); + throwsA(isA().having((e) => e.message, + 'message', 'Candidate was blocked due to safety'))); }); test( - 'throws VertexAIException if first candidate finished due to recitation', + 'throws FirebaseAIException if first candidate finished due to recitation', () { final response = GenerateContentResponse([candidateFinishedRecitation], null); expect( () => response.text, - throwsA(isA().having((e) => e.message, 'message', + throwsA(isA().having( + (e) => e.message, + 'message', 'Candidate was blocked due to recitation: Recited content'))); }); @@ -176,33 +181,38 @@ void main() { group('Candidate', () { final textContent = Content.text('Test text'); group('.text getter', () { - test('throws VertexAIException if finishReason is safety with message', + test('throws FirebaseAIException if finishReason is safety with message', () { final candidate = Candidate(textContent, null, null, FinishReason.safety, 'Safety block message'); expect( () => candidate.text, - throwsA(isA().having((e) => e.message, 'message', + throwsA(isA().having( + (e) => e.message, + 'message', 'Candidate was blocked due to safety: Safety block message'))); }); - test('throws VertexAIException if finishReason is safety without message', + test( + 'throws FirebaseAIException if finishReason is safety without message', () { final candidate = Candidate( textContent, null, null, FinishReason.safety, ''); // Empty message expect( () => candidate.text, - throwsA(isA().having((e) => e.message, 'message', - 'Candidate was blocked due to safety'))); + throwsA(isA().having((e) => e.message, + 'message', 'Candidate was blocked due to safety'))); }); test( - 'throws VertexAIException if finishReason is recitation with message', + 'throws FirebaseAIException if finishReason is recitation with message', () { final candidate = Candidate(textContent, null, null, FinishReason.recitation, 'Recitation block message'); expect( () => candidate.text, - throwsA(isA().having((e) => e.message, 'message', + throwsA(isA().having( + (e) => e.message, + 'message', 'Candidate was blocked due to recitation: Recitation block message'))); }); @@ -443,7 +453,7 @@ void main() { {'modality': 'IMAGE', 'tokenCount': 20} ] }; - final response = parseCountTokensResponse(json); + final response = VertexSerialization().parseCountTokensResponse(json); expect(response.totalTokens, 120); expect(response.totalBillableCharacters, 240); expect(response.promptTokensDetails, isNotNull); @@ -457,31 +467,31 @@ void main() { test('parses valid JSON with minimal fields (only totalTokens)', () { final json = {'totalTokens': 50}; - final response = parseCountTokensResponse(json); + final response = VertexSerialization().parseCountTokensResponse(json); expect(response.totalTokens, 50); expect(response.totalBillableCharacters, isNull); expect(response.promptTokensDetails, isNull); }); - test('throws VertexAIException if JSON contains error field', () { + test('throws FirebaseAIException if JSON contains error field', () { final json = { 'error': {'code': 400, 'message': 'Invalid request'} }; - expect(() => parseCountTokensResponse(json), - throwsA(isA())); + expect(() => VertexSerialization().parseCountTokensResponse(json), + throwsA(isA())); }); test('throws FormatException for invalid JSON structure (not a Map)', () { const json = 'not_a_map'; expect( - () => parseCountTokensResponse(json), - throwsA(isA().having( + () => VertexSerialization().parseCountTokensResponse(json), + throwsA(isA().having( (e) => e.message, 'message', contains('CountTokensResponse')))); }); test('throws if totalTokens is missing', () { final json = {'totalBillableCharacters': 100}; - expect(() => parseCountTokensResponse(json), + expect(() => VertexSerialization().parseCountTokensResponse(json), throwsA(anything)); // More specific error expected }); }); @@ -531,7 +541,8 @@ void main() { ], } }; - final response = parseGenerateContentResponse(json); + final response = + VertexSerialization().parseGenerateContentResponse(json); expect(response.candidates, hasLength(1)); expect(response.candidates.first.text, 'Hello world'); expect(response.candidates.first.finishReason, FinishReason.stop); @@ -563,7 +574,8 @@ void main() { test('parses JSON with no candidates (empty list)', () { final json = {'candidates': []}; - final response = parseGenerateContentResponse(json); + final response = + VertexSerialization().parseGenerateContentResponse(json); expect(response.candidates, isEmpty); expect(response.promptFeedback, isNull); expect(response.usageMetadata, isNull); @@ -572,7 +584,8 @@ void main() { test('parses JSON with null candidates (treated as empty)', () { // The code defaults to [] if 'candidates' key is missing final json = {'promptFeedback': null}; - final response = parseGenerateContentResponse(json); + final response = + VertexSerialization().parseGenerateContentResponse(json); expect(response.candidates, isEmpty); expect(response.promptFeedback, isNull); }); @@ -590,7 +603,8 @@ void main() { } ] }; - final response = parseGenerateContentResponse(json); + final response = + VertexSerialization().parseGenerateContentResponse(json); expect(response.candidates, hasLength(1)); expect(response.candidates.first.text, 'Minimal'); expect(response.candidates.first.finishReason, isNull); @@ -616,7 +630,8 @@ void main() { ], } }; - final response = parseGenerateContentResponse(json); + final response = + VertexSerialization().parseGenerateContentResponse(json); expect(response.candidates, hasLength(1)); expect(response.candidates.first.text, 'Hello world'); expect(response.candidates.first.finishReason, FinishReason.stop); @@ -662,7 +677,8 @@ void main() { } ] }; - final response = parseGenerateContentResponse(json); + final response = + VertexSerialization().parseGenerateContentResponse(json); final candidate = response.candidates.first; expect(candidate.citationMetadata, isNotNull); expect(candidate.citationMetadata!.citations, hasLength(1)); @@ -692,7 +708,8 @@ void main() { } ] }; - final response = parseGenerateContentResponse(json); + final response = + VertexSerialization().parseGenerateContentResponse(json); final candidate = response.candidates.first; expect(candidate.citationMetadata, isNotNull); expect(candidate.citationMetadata!.citations, hasLength(1)); @@ -701,12 +718,12 @@ void main() { expect(candidate.citationMetadata!.citations.first.license, 'MIT'); }); - test('throws VertexAIException if JSON contains error field', () { + test('throws FirebaseAIException if JSON contains error field', () { final json = { 'error': {'code': 500, 'message': 'Internal server error'} }; - expect(() => parseGenerateContentResponse(json), - throwsA(isA())); + expect(() => VertexSerialization().parseGenerateContentResponse(json), + throwsA(isA())); }); test('handles missing content in candidate gracefully (empty content)', @@ -719,7 +736,8 @@ void main() { } ] }; - final response = parseGenerateContentResponse(json); + final response = + VertexSerialization().parseGenerateContentResponse(json); expect(response.candidates, hasLength(1)); expect(response.candidates.first.content.parts, isEmpty); expect(response.candidates.first.text, isNull); @@ -730,8 +748,9 @@ void main() { 'candidates': ['not_a_map_candidate'] }; expect( - () => parseGenerateContentResponse(jsonResponse), - throwsA(isA() + () => VertexSerialization() + .parseGenerateContentResponse(jsonResponse), + throwsA(isA() .having((e) => e.message, 'message', contains('Candidate')))); }); @@ -745,8 +764,9 @@ void main() { ] }; expect( - () => parseGenerateContentResponse(jsonResponse), - throwsA(isA().having( + () => VertexSerialization() + .parseGenerateContentResponse(jsonResponse), + throwsA(isA().having( (e) => e.message, 'message', contains('SafetyRating')))); }); test('throws FormatException for invalid citation metadata structure', @@ -760,22 +780,25 @@ void main() { ] }; expect( - () => parseGenerateContentResponse(jsonResponse), - throwsA(isA().having( + () => VertexSerialization() + .parseGenerateContentResponse(jsonResponse), + throwsA(isA().having( (e) => e.message, 'message', contains('CitationMetadata')))); }); test('throws FormatException for invalid prompt feedback structure', () { final jsonResponse = {'promptFeedback': 'not_a_map_feedback'}; expect( - () => parseGenerateContentResponse(jsonResponse), - throwsA(isA().having( + () => VertexSerialization() + .parseGenerateContentResponse(jsonResponse), + throwsA(isA().having( (e) => e.message, 'message', contains('PromptFeedback')))); }); test('throws FormatException for invalid usage metadata structure', () { final jsonResponse = {'usageMetadata': 'not_a_map_usage'}; expect( - () => parseGenerateContentResponse(jsonResponse), - throwsA(isA().having( + () => VertexSerialization() + .parseGenerateContentResponse(jsonResponse), + throwsA(isA().having( (e) => e.message, 'message', contains('UsageMetadata')))); }); test('throws FormatException for invalid modality token count structure', @@ -786,8 +809,9 @@ void main() { } }; expect( - () => parseGenerateContentResponse(jsonResponse), - throwsA(isA().having( + () => VertexSerialization() + .parseGenerateContentResponse(jsonResponse), + throwsA(isA().having( (e) => e.message, 'message', contains('ModalityTokenCount')))); }); }); diff --git a/packages/firebase_vertexai/firebase_vertexai/test/chat_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/chat_test.dart index 51afe2a9b495..026d14dca580 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/chat_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/chat_test.dart @@ -11,9 +11,10 @@ // 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/base_model.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_vertexai/firebase_vertexai.dart'; -import 'package:firebase_vertexai/src/base_model.dart'; import 'package:flutter_test/flutter_test.dart'; import 'mock.dart'; @@ -38,6 +39,7 @@ void main() { final client = ClientController(); final model = createModelWithClient( app: app, + useVertexBackend: true, model: modelName, client: client.client, location: 'us-central1'); diff --git a/packages/firebase_vertexai/firebase_vertexai/test/content_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/content_test.dart index 5aa62fc32aa0..21db7a03f852 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/content_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/content_test.dart @@ -15,8 +15,9 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:firebase_vertexai/src/content.dart'; -import 'package:firebase_vertexai/src/error.dart'; +import 'package:firebase_ai/src/content.dart'; +import 'package:firebase_vertexai/firebase_vertexai.dart' + show VertexAISdkException; import 'package:flutter_test/flutter_test.dart'; // Mock google_ai classes (if needed) diff --git a/packages/firebase_vertexai/firebase_vertexai/test/error_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/error_test.dart index f548c7ad97df..83bff112adbe 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/error_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/error_test.dart @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:firebase_vertexai/src/error.dart'; +import 'package:firebase_ai/src/error.dart'; +import 'package:firebase_vertexai/firebase_vertexai.dart' + show VertexAIException, VertexAISdkException; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -62,7 +64,7 @@ void main() { 'SDK failed to parse response.\n' 'This indicates a problem with the Vertex AI in Firebase SDK. ' 'Try updating to the latest version ' - '(https://pub.dev/packages/firebase_vertexai/versions), ' + '(https://pub.dev/packages/firebase_ai/versions), ' 'or file an issue at ' 'https://github.com/firebase/flutterfire/issues.'); }); diff --git a/packages/firebase_vertexai/firebase_vertexai/test/google_ai_generative_model_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/google_ai_generative_model_test.dart new file mode 100644 index 000000000000..9be8a316a11d --- /dev/null +++ b/packages/firebase_vertexai/firebase_vertexai/test/google_ai_generative_model_test.dart @@ -0,0 +1,731 @@ +// Copyright 2025 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/base_model.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_vertexai/firebase_vertexai.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'mock.dart'; +import 'utils/matchers.dart'; +import 'utils/stub_client.dart'; + +void main() { + setupFirebaseVertexAIMocks(); + late FirebaseApp app; + setUpAll(() async { + // Initialize Firebase + app = await Firebase.initializeApp(); + }); + group('GenerativeModel', () { + const defaultModelName = 'some-model'; + + (ClientController, GenerativeModel) createModel({ + String modelName = defaultModelName, + List? tools, + ToolConfig? toolConfig, + Content? systemInstruction, + }) { + final client = ClientController(); + final model = createModelWithClient( + useVertexBackend: false, + app: app, + model: modelName, + client: client.client, + tools: tools, + toolConfig: toolConfig, + systemInstruction: systemInstruction, + location: 'us-central1'); + return (client, model); + } + + test('strips leading "models/" from model name', () async { + final (client, model) = createModel( + modelName: 'models/$defaultModelName', + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + response: arbitraryGenerateContentResponse, + verifyRequest: (uri, _) { + expect(uri.path, endsWith('/models/some-model:generateContent')); + }, + ); + }); + + test('allows specifying a tuned model', () async { + final (client, model) = createModel( + modelName: 'tunedModels/$defaultModelName', + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + response: arbitraryGenerateContentResponse, + verifyRequest: (uri, _) { + expect(uri.path, endsWith('/tunedModels/some-model:generateContent')); + }, + ); + }); + + test('allows specifying an API version', () async { + final (client, model) = createModel( + // requestOptions: RequestOptions(apiVersion: 'override_version'), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + response: arbitraryGenerateContentResponse, + verifyRequest: (uri, _) { + expect(uri.path, startsWith('/override_version/')); + }, + ); + }, skip: 'No support for overriding API version'); + + group('generate unary content', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + const result = 'Some response'; + final response = await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:generateContent', + ), + ); + expect(request, { + 'model': 'models/$defaultModelName', + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + }); + }, + response: { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [ + {'text': result}, + ], + }, + }, + ], + }, + ); + expect( + response, + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content('model', [TextPart(result)]), + null, + null, + null, + null, + ), + ], null), + ), + ); + }); + + test('can override safety settings', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent( + [Content.text(prompt)], + safetySettings: [ + SafetySetting( + HarmCategory.dangerousContent, + HarmBlockThreshold.high, + null, + ), + ], + ), + response: arbitraryGenerateContentResponse, + verifyRequest: (_, request) { + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + ]); + }, + ); + }); + + test('can override generation config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([ + Content.text(prompt), + ], generationConfig: GenerationConfig(stopSequences: ['a'])), + verifyRequest: (_, request) { + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can pass system instructions', () async { + const instructions = 'Do a good job'; + final (client, model) = createModel( + systemInstruction: Content.system(instructions), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['systemInstruction'], { + 'role': 'system', + 'parts': [ + {'text': instructions}, + ], + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can pass tools and function calling config', () async { + final (client, model) = createModel( + tools: [ + Tool.functionDeclarations([ + FunctionDeclaration( + 'someFunction', + 'Some cool function.', + parameters: { + 'schema1': Schema.string(description: 'Some parameter.'), + }, + ), + ]), + ], + toolConfig: ToolConfig( + functionCallingConfig: FunctionCallingConfig.any( + {'someFunction'}, + ), + ), + ); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['tools'], [ + { + 'functionDeclarations': [ + { + 'name': 'someFunction', + 'description': 'Some cool function.', + 'parameters': { + 'type': 'OBJECT', + 'properties': { + 'schema1': { + 'type': 'STRING', + 'description': 'Some parameter.' + } + }, + 'required': ['schema1'] + } + }, + ], + }, + ]); + expect(request['toolConfig'], { + 'functionCallingConfig': { + 'mode': 'ANY', + 'allowedFunctionNames': ['someFunction'], + }, + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can override tools and function calling config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent( + [Content.text(prompt)], + tools: [ + Tool.functionDeclarations([ + FunctionDeclaration( + 'someFunction', + 'Some cool function.', + parameters: { + 'schema1': Schema.string(description: 'Some parameter.'), + }, + ), + ]), + ], + toolConfig: ToolConfig( + functionCallingConfig: FunctionCallingConfig.any( + {'someFunction'}, + ), + ), + ), + verifyRequest: (_, request) { + expect(request['tools'], [ + { + 'functionDeclarations': [ + { + 'name': 'someFunction', + 'description': 'Some cool function.', + 'parameters': { + 'type': 'OBJECT', + 'properties': { + 'schema1': { + 'type': 'STRING', + 'description': 'Some parameter.' + } + }, + 'required': ['schema1'] + } + }, + ], + }, + ]); + expect(request['toolConfig'], { + 'functionCallingConfig': { + 'mode': 'ANY', + 'allowedFunctionNames': ['someFunction'], + }, + }); + }, + response: arbitraryGenerateContentResponse, + ); + }); + + test('can enable code execution', () async { + final (client, model) = createModel(tools: [ + // Tool(codeExecution: CodeExecution()), + ]); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([Content.text(prompt)]), + verifyRequest: (_, request) { + expect(request['tools'], [ + {'codeExecution': {}} + ]); + }, + response: arbitraryGenerateContentResponse, + ); + }, skip: 'No support for code executation'); + + test('can override code execution', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + () => model.generateContent([ + Content.text(prompt) + ], tools: [ + // Tool(codeExecution: CodeExecution()), + ]), + verifyRequest: (_, request) { + expect(request['tools'], [ + {'codeExecution': {}} + ]); + }, + response: arbitraryGenerateContentResponse, + ); + }, skip: 'No support for code execution'); + }); + + group('generate content stream', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final results = {'First response', 'Second Response'}; + final response = await client.checkStreamRequest( + () async => model.generateContentStream([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:streamGenerateContent', + ), + ); + expect(request, { + 'model': 'models/$defaultModelName', + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + }); + }, + responses: [ + for (final result in results) + { + 'candidates': [ + { + 'content': { + 'role': 'model', + 'parts': [ + {'text': result}, + ], + }, + }, + ], + }, + ], + ); + expect( + response, + emitsInOrder([ + for (final result in results) + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content('model', [TextPart(result)]), + null, + null, + null, + null, + ), + ], null), + ), + ]), + ); + }); + + test('can override safety settings', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final responses = await client.checkStreamRequest( + () async => model.generateContentStream( + [Content.text(prompt)], + safetySettings: [ + SafetySetting( + HarmCategory.dangerousContent, + HarmBlockThreshold.high, + null, + ), + ], + ), + verifyRequest: (_, request) { + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + ]); + }, + responses: [arbitraryGenerateContentResponse], + ); + await responses.drain(); + }); + + test('can override generation config', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final responses = await client.checkStreamRequest( + () async => model.generateContentStream([ + Content.text(prompt), + ], generationConfig: GenerationConfig(stopSequences: ['a'])), + verifyRequest: (_, request) { + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + }, + responses: [arbitraryGenerateContentResponse], + ); + await responses.drain(); + }); + }); + + group('count tokens', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final response = await client.checkRequest( + () => model.countTokens([Content.text(prompt)]), + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:countTokens', + ), + ); + expect(request, { + 'generateContentRequest': { + 'model': 'models/$defaultModelName', + 'contents': [ + { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + ], + } + }); + }, + response: {'totalTokens': 2}, + ); + expect(response, matchesCountTokensResponse(CountTokensResponse(2))); + }); + + test('can override GenerateContentRequest fields', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + await client.checkRequest( + response: {'totalTokens': 100}, + () => model.countTokens( + [Content.text(prompt)], + // safetySettings: [ + // SafetySetting( + // HarmCategory.dangerousContent, + // HarmBlockThreshold.high, + // null, + // ), + // ], + // generationConfig: GenerationConfig(stopSequences: ['a']), + // tools: [ + // Tool(functionDeclarations: [ + // FunctionDeclaration( + // 'someFunction', + // 'Some cool function.', + // Schema(SchemaType.string, description: 'Some parameter.'), + // ), + // ]), + // ], + // toolConfig: ToolConfig( + // functionCallingConfig: FunctionCallingConfig( + // mode: FunctionCallingMode.any, + // allowedFunctionNames: {'someFunction'}, + // ), + // ), + ), + verifyRequest: (_, countTokensRequest) { + expect(countTokensRequest, isNotNull); + final request = countTokensRequest['generateContentRequest']! + as Map; + expect(request['safetySettings'], [ + { + 'category': 'HARM_CATEGORY_DANGEROUS_CONTENT', + 'threshold': 'BLOCK_ONLY_HIGH', + }, + ]); + expect(request['generationConfig'], { + 'stopSequences': ['a'], + }); + expect(request['tools'], [ + { + 'functionDeclarations': [ + { + 'name': 'someFunction', + 'description': 'Some cool function.', + 'parameters': { + 'type': 'STRING', + 'description': 'Some parameter.', + }, + }, + ], + }, + ]); + expect(request['toolConfig'], { + 'functionCallingConfig': { + 'mode': 'ANY', + 'allowedFunctionNames': ['someFunction'], + }, + }); + }, + ); + }, skip: 'Only content argument supported for countTokens'); + }); + + group('embed content', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt = 'Some prompt'; + final response = await client.checkRequest( + () async { + // await model.embedContent(Content.text(prompt)); + }, + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:embedContent', + ), + ); + expect(request, { + 'content': { + 'role': 'user', + 'parts': [ + {'text': prompt}, + ], + }, + }); + }, + response: { + 'embedding': { + 'values': [0.1, 0.2, 0.3], + }, + }, + ); + expect( + response, + // matchesEmbedContentResponse( + // EmbedContentResponse(ContentEmbedding([0.1, 0.2, 0.3])), + // ), + isNotNull, + ); + }); + + test('embed content with reduced output dimensionality', () async { + final (client, model) = createModel(); + const content = 'Some content'; + const outputDimensionality = 1; + final embeddingValues = [0.1]; + + await client.checkRequest(() async { + Content.text(content); + // await model.embedContent( + // Content.text(content), + // outputDimensionality: outputDimensionality, + // ); + }, verifyRequest: (_, request) { + expect(request, + containsPair('outputDimensionality', outputDimensionality)); + }, response: { + 'embedding': {'values': embeddingValues}, + }); + }); + }, skip: 'No support for embedding content'); + + group('batch embed contents', () { + test('can make successful request', () async { + final (client, model) = createModel(); + const prompt1 = 'Some prompt'; + const prompt2 = 'Another prompt'; + final embedding1 = [0.1, 0.2, 0.3]; + final embedding2 = [0.4, 0.5, 1.6]; + final response = await client.checkRequest( + () async { + // await model.batchEmbedContents([ + // EmbedContentRequest(Content.text(prompt1)), + // EmbedContentRequest(Content.text(prompt2)), + // ]); + }, + verifyRequest: (uri, request) { + expect( + uri, + Uri.parse( + 'https://firebasevertexai.googleapis.com/v1beta/' + 'projects/123/' + 'models/some-model:batchEmbedContents', + ), + ); + expect(request, { + 'requests': [ + { + 'content': { + 'role': 'user', + 'parts': [ + {'text': prompt1}, + ], + }, + 'model': 'models/$defaultModelName', + }, + { + 'content': { + 'role': 'user', + 'parts': [ + {'text': prompt2}, + ], + }, + 'model': 'models/$defaultModelName', + }, + ], + }); + }, + response: { + 'embeddings': [ + {'values': embedding1}, + {'values': embedding2}, + ], + }, + ); + expect( + response, + isNotNull, + // matchesBatchEmbedContentsResponse( + // BatchEmbedContentsResponse([ + // ContentEmbedding(embedding1), + // ContentEmbedding(embedding2), + // ]), + // ), + ); + }); + + test('batch embed contents with reduced output dimensionality', () async { + final (client, model) = createModel(); + const content1 = 'Some content 1'; + const content2 = 'Some content 2'; + const outputDimensionality = 1; + final embeddingValues1 = [0.1]; + final embeddingValues2 = [0.4]; + + await client.checkRequest(() async { + Content.text(content1); + Content.text(content2); + // await model.batchEmbedContents([ + // EmbedContentRequest( + // Content.text(content1), + // outputDimensionality: outputDimensionality, + // ), + // EmbedContentRequest( + // Content.text(content2), + // outputDimensionality: outputDimensionality, + // ), + // ]); + }, verifyRequest: (_, request) { + expect(request['requests'], [ + containsPair('outputDimensionality', outputDimensionality), + containsPair('outputDimensionality', outputDimensionality), + ]); + }, response: { + 'embeddings': [ + {'values': embeddingValues1}, + {'values': embeddingValues2}, + ], + }); + }); + }, skip: 'No support for embed content'); + }); +} diff --git a/packages/firebase_vertexai/firebase_vertexai/test/google_ai_response_parsing_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/google_ai_response_parsing_test.dart new file mode 100644 index 000000000000..76dc0adebf65 --- /dev/null +++ b/packages/firebase_vertexai/firebase_vertexai/test/google_ai_response_parsing_test.dart @@ -0,0 +1,770 @@ +// Copyright 2025 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 'dart:convert'; + +import 'package:firebase_ai/src/developer/api.dart'; +import 'package:firebase_vertexai/firebase_vertexai.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'utils/matchers.dart'; + +void main() { + group('throws errors for invalid GenerateContentResponse', () { + test('with empty content', () { + const response = ''' +{ + "candidates": [ + { + "content": {}, + "index": 0 + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + expect( + () => DeveloperSerialization().parseGenerateContentResponse(decoded), + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Unhandled format for Content:'), + ), + ), + ); + }); + + test('with a blocked prompt', () { + const response = ''' +{ + "promptFeedback": { + "blockReason": "SAFETY", + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "HIGH" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [], + PromptFeedback(BlockReason.safety, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.high), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + expect( + () => generateContentResponse.text, + throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith('Response was blocked due to safety'), + ), + ), + ); + }); + }); + + group('parses successful GenerateContentResponse', () { + test('with a basic reply', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Mountain View, California, United States" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([ + TextPart('Mountain View, California, United States'), + ]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + null, + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('with a citation', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "placeholder" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "citationMetadata": { + "citationSources": [ + { + "startIndex": 574, + "endIndex": 705, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026 + }, + { + "uri": "https://example.com/", + "license": "" + }, + {} + ] + } + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([TextPart('placeholder')]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([ + Citation(574, 705, Uri.https('example.com'), ''), + Citation(899, 1026, Uri.https('example.com'), ''), + ]), + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('with a vertex formatted citation', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "placeholder" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ], + "citationMetadata": { + "citations": [ + { + "startIndex": 574, + "endIndex": 705, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026, + "uri": "https://example.com/", + "license": "" + }, + { + "startIndex": 899, + "endIndex": 1026 + }, + { + "uri": "https://example.com/", + "license": "" + }, + {} + ] + } + } + ], + "promptFeedback": { + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([TextPart('placeholder')]), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.harassment, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([ + Citation(574, 705, Uri.https('example.com'), ''), + Citation(899, 1026, Uri.https('example.com'), ''), + ]), + FinishReason.stop, + null, + ), + ], + PromptFeedback(null, null, [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating(HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating(HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ]), + ), + ), + ); + }); + + test('with code execution', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "executableCode": { + "language": "PYTHON", + "code": "print('hello world')" + } + }, + { + "codeExecutionResult": { + "outcome": "OUTCOME_OK", + "output": "hello world" + } + }, + { + "text": "hello world" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse( + [ + Candidate( + Content.model([ + // ExecutableCode(Language.python, 'print(\'hello world\')'), + // CodeExecutionResult(Outcome.ok, 'hello world'), + TextPart('hello world') + ]), + [], + null, + FinishReason.stop, + null, + ), + ], + null, + ), + ), + ); + }, skip: 'Code Execution Unsupported'); + + test('allows missing content', () async { + const response = ''' +{ + "candidates": [ + { + "finishReason": "SAFETY", + "index": 0, + "safetyRatings": [ + { + "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", + "probability": "NEGLIGIBLE" + }, + { + "category": "HARM_CATEGORY_HATE_SPEECH", + "probability": "LOW" + }, + { + "category": "HARM_CATEGORY_HARASSMENT", + "probability": "MEDIUM" + }, + { + "category": "HARM_CATEGORY_DANGEROUS_CONTENT", + "probability": "NEGLIGIBLE" + } + ] + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect( + generateContentResponse, + matchesGenerateContentResponse( + GenerateContentResponse([ + Candidate( + Content(null, []), + [ + SafetyRating( + HarmCategory.sexuallyExplicit, + HarmProbability.negligible, + ), + SafetyRating( + HarmCategory.hateSpeech, HarmProbability.negligible), + SafetyRating( + HarmCategory.harassment, HarmProbability.negligible), + SafetyRating( + HarmCategory.dangerousContent, + HarmProbability.negligible, + ), + ], + CitationMetadata([]), + FinishReason.safety, + null), + ], null), + ), + ); + }); + + test('text getter joins content', () async { + const response = ''' +{ + "candidates": [ + { + "content": { + "parts": [ + { + "text": "Initial text" + }, + { + "functionCall": {"name": "someFunction", "args": {}} + }, + { + "text": " And more text" + } + ], + "role": "model" + }, + "finishReason": "STOP", + "index": 0 + } + ] +} +'''; + final decoded = jsonDecode(response) as Object; + final generateContentResponse = + DeveloperSerialization().parseGenerateContentResponse(decoded); + expect(generateContentResponse.text, 'Initial text And more text'); + expect(generateContentResponse.candidates.single.text, + 'Initial text And more text'); + }); + }); + + group('parses and throws error responses', () { + test('for invalid API key', () async { + const response = ''' +{ + "error": { + "code": 400, + "message": "API key not valid. Please pass a valid API key.", + "status": "INVALID_ARGUMENT", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "API_KEY_INVALID", + "domain": "googleapis.com", + "metadata": { + "service": "generativelanguage.googleapis.com" + } + }, + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "Invalid API key: AIzv00G7VmUCUeC-5OglO3hcXM" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + 'API key not valid. Please pass a valid API key.', + ), + ); + expect( + () => DeveloperSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => DeveloperSerialization().parseCountTokensResponse(decoded), + expectedThrow); + // expect(() => parseEmbedContentResponse(decoded), expectedThrow); + }); + + test('for unsupported user location', () async { + const response = r''' +{ + "error": { + "code": 400, + "message": "User location is not supported for the API use.", + "status": "FAILED_PRECONDITION", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::failed_precondition: User location is not supported for the API use. [google.rpc.error_details_ext] { message: \"User location is not supported for the API use.\" }" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + 'User location is not supported for the API use.', + ), + ); + expect( + () => DeveloperSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => DeveloperSerialization().parseCountTokensResponse(decoded), + expectedThrow); + // expect(() => parseEmbedContentResponse(decoded), expectedThrow); + }); + + test('for general server errors', () async { + const response = r''' +{ + "error": { + "code": 404, + "message": "models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.", + "status": "NOT_FOUND", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.DebugInfo", + "detail": "[ORIGINAL ERROR] generic::not_found: models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods. [google.rpc.error_details_ext] { message: \"models/unknown is not found for API version v1, or is not supported for GenerateContent. Call ListModels to see the list of available models and their supported methods.\" }" + } + ] + } +} +'''; + final decoded = jsonDecode(response) as Object; + final expectedThrow = throwsA( + isA().having( + (e) => e.message, + 'message', + startsWith( + 'models/unknown is not found for API version v1, ' + 'or is not supported for GenerateContent.', + ), + ), + ); + expect( + () => DeveloperSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => DeveloperSerialization().parseCountTokensResponse(decoded), + expectedThrow); + // expect(() => parseEmbedContentResponse(decoded), expectedThrow); + }); + }); +} diff --git a/packages/firebase_vertexai/firebase_vertexai/test/imagen_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/imagen_test.dart index d030e6f89495..4bd7ae5b763a 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/imagen_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/imagen_test.dart @@ -15,8 +15,8 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:firebase_vertexai/src/error.dart'; -import 'package:firebase_vertexai/src/imagen_content.dart'; +import 'package:firebase_ai/src/error.dart'; +import 'package:firebase_ai/src/imagen_content.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/firebase_vertexai/firebase_vertexai/test/live_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/live_test.dart index b8b73d17e0ca..14909bcbc0eb 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/live_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/live_test.dart @@ -13,10 +13,11 @@ // limitations under the License. import 'dart:typed_data'; -import 'package:firebase_vertexai/src/api.dart'; -import 'package:firebase_vertexai/src/content.dart'; -import 'package:firebase_vertexai/src/error.dart'; -import 'package:firebase_vertexai/src/live_api.dart'; +import 'package:firebase_ai/src/api.dart'; +import 'package:firebase_ai/src/content.dart'; +import 'package:firebase_ai/src/live_api.dart'; +import 'package:firebase_vertexai/firebase_vertexai.dart' + show VertexAISdkException; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/firebase_vertexai/firebase_vertexai/test/model_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/model_test.dart index 57c3fbaaa5ed..a0727913a68b 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/model_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/model_test.dart @@ -11,9 +11,10 @@ // 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/base_model.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_vertexai/firebase_vertexai.dart'; -import 'package:firebase_vertexai/src/base_model.dart'; import 'package:flutter_test/flutter_test.dart'; import 'mock.dart'; @@ -39,6 +40,7 @@ void main() { }) { final client = ClientController(); final model = createModelWithClient( + useVertexBackend: true, app: app, model: modelName, client: client.client, diff --git a/packages/firebase_vertexai/firebase_vertexai/test/response_parsing_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/response_parsing_test.dart index d5c8d64f16d5..97b2d580877c 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/response_parsing_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/response_parsing_test.dart @@ -14,9 +14,9 @@ import 'dart:convert'; +import 'package:firebase_ai/src/api.dart'; +import 'package:firebase_ai/src/error.dart'; import 'package:firebase_vertexai/firebase_vertexai.dart'; -import 'package:firebase_vertexai/src/api.dart'; -import 'package:firebase_vertexai/src/error.dart'; import 'package:flutter_test/flutter_test.dart'; import 'utils/matchers.dart'; @@ -56,7 +56,7 @@ void main() { '''; final decoded = jsonDecode(response) as Object; expect( - () => parseGenerateContentResponse(decoded), + () => VertexSerialization().parseGenerateContentResponse(decoded), throwsA( isA().having( (e) => e.message, @@ -94,7 +94,8 @@ void main() { } '''; final decoded = jsonDecode(response) as Object; - final generateContentResponse = parseGenerateContentResponse(decoded); + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); expect( generateContentResponse, matchesGenerateContentResponse( @@ -158,7 +159,7 @@ void main() { '''; final decoded = jsonDecode(response) as Object; expect( - () => parseGenerateContentResponse(decoded), + () => VertexSerialization().parseGenerateContentResponse(decoded), throwsA( isA().having( (e) => e.message, @@ -206,7 +207,7 @@ void main() { '''; final decoded = jsonDecode(response) as Object; expect( - () => parseGenerateContentResponse(decoded), + () => VertexSerialization().parseGenerateContentResponse(decoded), throwsA( isA().having( (e) => e.message, @@ -277,7 +278,8 @@ void main() { } '''; final decoded = jsonDecode(response) as Object; - final generateContentResponse = parseGenerateContentResponse(decoded); + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); expect( generateContentResponse, matchesGenerateContentResponse( @@ -410,7 +412,8 @@ void main() { } '''; final decoded = jsonDecode(response) as Object; - final generateContentResponse = parseGenerateContentResponse(decoded); + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); expect( generateContentResponse, matchesGenerateContentResponse( @@ -544,7 +547,8 @@ void main() { } '''; final decoded = jsonDecode(response) as Object; - final generateContentResponse = parseGenerateContentResponse(decoded); + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); expect( generateContentResponse, matchesGenerateContentResponse( @@ -625,7 +629,8 @@ void main() { } '''; final decoded = jsonDecode(response) as Object; - final generateContentResponse = parseGenerateContentResponse(decoded); + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); expect( generateContentResponse, matchesGenerateContentResponse( @@ -685,7 +690,8 @@ void main() { } '''; final decoded = jsonDecode(response) as Object; - final generateContentResponse = parseGenerateContentResponse(decoded); + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); expect( generateContentResponse.text, 'Here is a description of the image:'); expect(generateContentResponse.usageMetadata?.totalTokenCount, 1913); @@ -722,7 +728,8 @@ void main() { } '''; final decoded = jsonDecode(response) as Object; - final countTokensResponse = parseCountTokensResponse(decoded); + final countTokensResponse = + VertexSerialization().parseCountTokensResponse(decoded); expect(countTokensResponse.totalTokens, 1837); expect(countTokensResponse.promptTokensDetails?.first.modality, ContentModality.image); @@ -755,7 +762,8 @@ void main() { } '''; final decoded = jsonDecode(response) as Object; - final generateContentResponse = parseGenerateContentResponse(decoded); + final generateContentResponse = + VertexSerialization().parseGenerateContentResponse(decoded); expect(generateContentResponse.text, 'Initial text And more text'); expect(generateContentResponse.candidates.single.text, 'Initial text And more text'); @@ -795,8 +803,10 @@ void main() { 'API key not valid. Please pass a valid API key.', ), ); - expect(() => parseGenerateContentResponse(decoded), expectedThrow); - expect(() => parseCountTokensResponse(decoded), expectedThrow); + expect(() => VertexSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => VertexSerialization().parseCountTokensResponse(decoded), + expectedThrow); }); test('for unsupported user location', () async { @@ -823,8 +833,10 @@ void main() { 'User location is not supported for the API use.', ), ); - expect(() => parseGenerateContentResponse(decoded), expectedThrow); - expect(() => parseCountTokensResponse(decoded), expectedThrow); + expect(() => VertexSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => VertexSerialization().parseCountTokensResponse(decoded), + expectedThrow); }); test('for general server errors', () async { @@ -854,8 +866,10 @@ void main() { ), ), ); - expect(() => parseGenerateContentResponse(decoded), expectedThrow); - expect(() => parseCountTokensResponse(decoded), expectedThrow); + expect(() => VertexSerialization().parseGenerateContentResponse(decoded), + expectedThrow); + expect(() => VertexSerialization().parseCountTokensResponse(decoded), + expectedThrow); }); }); } diff --git a/packages/firebase_vertexai/firebase_vertexai/test/schema_test.dart b/packages/firebase_vertexai/firebase_vertexai/test/schema_test.dart index c6de40ee0710..e4b47be4be94 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/schema_test.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/schema_test.dart @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import 'package:firebase_vertexai/src/schema.dart'; +import 'package:firebase_ai/src/schema.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/firebase_vertexai/firebase_vertexai/test/utils/stub_client.dart b/packages/firebase_vertexai/firebase_vertexai/test/utils/stub_client.dart index 11b450f85c37..fa0704316574 100644 --- a/packages/firebase_vertexai/firebase_vertexai/test/utils/stub_client.dart +++ b/packages/firebase_vertexai/firebase_vertexai/test/utils/stub_client.dart @@ -14,7 +14,7 @@ import 'dart:collection'; -import 'package:firebase_vertexai/src/client.dart'; +import 'package:firebase_ai/src/client.dart'; class ClientController { final _client = _ControlledClient();