From d40e3145fe3f7eca87e2e5fed784de1a8ea4a65f Mon Sep 17 00:00:00 2001
From: tronikos <tronikos@users.noreply.github.com>
Date: Mon, 2 Sep 2024 04:30:18 -0700
Subject: [PATCH] Setup Google Cloud from the UI (#121502)

* Google Cloud can now be setup from the UI

* mypy

* Add BaseGoogleCloudProvider

* Allow clearing options in the UI

* Address feedback

* Don't translate Google Cloud title

* mypy

* Revert strict typing changes

* Address comments
---
 CODEOWNERS                                    |   3 +-
 .../components/google_cloud/__init__.py       |  25 +++
 .../components/google_cloud/config_flow.py    | 169 ++++++++++++++++
 .../components/google_cloud/const.py          |   4 +
 .../components/google_cloud/helpers.py        |  44 +++--
 .../components/google_cloud/manifest.json     |   7 +-
 .../components/google_cloud/strings.json      |  32 +++
 homeassistant/components/google_cloud/tts.py  | 134 +++++++++++--
 homeassistant/generated/config_flows.py       |   1 +
 homeassistant/generated/integrations.json     |   6 +-
 requirements_test_all.txt                     |   3 +
 tests/components/google_cloud/__init__.py     |   1 +
 tests/components/google_cloud/conftest.py     | 122 ++++++++++++
 .../google_cloud/test_config_flow.py          | 183 ++++++++++++++++++
 14 files changed, 696 insertions(+), 38 deletions(-)
 create mode 100644 homeassistant/components/google_cloud/config_flow.py
 create mode 100644 homeassistant/components/google_cloud/strings.json
 create mode 100644 tests/components/google_cloud/__init__.py
 create mode 100644 tests/components/google_cloud/conftest.py
 create mode 100644 tests/components/google_cloud/test_config_flow.py

diff --git a/CODEOWNERS b/CODEOWNERS
index 7b8b4ec1106..f4c7d972f7c 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -549,7 +549,8 @@ build.json @home-assistant/supervisor
 /tests/components/google_assistant/ @home-assistant/cloud
 /homeassistant/components/google_assistant_sdk/ @tronikos
 /tests/components/google_assistant_sdk/ @tronikos
-/homeassistant/components/google_cloud/ @lufton
+/homeassistant/components/google_cloud/ @lufton @tronikos
+/tests/components/google_cloud/ @lufton @tronikos
 /homeassistant/components/google_generative_ai_conversation/ @tronikos
 /tests/components/google_generative_ai_conversation/ @tronikos
 /homeassistant/components/google_mail/ @tkdrob
diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py
index 97b669245d2..84848543790 100644
--- a/homeassistant/components/google_cloud/__init__.py
+++ b/homeassistant/components/google_cloud/__init__.py
@@ -1 +1,26 @@
 """The google_cloud component."""
+
+from __future__ import annotations
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import Platform
+from homeassistant.core import HomeAssistant
+
+PLATFORMS = [Platform.TTS]
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+    """Set up a config entry."""
+    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
+    entry.async_on_unload(entry.add_update_listener(async_update_options))
+    return True
+
+
+async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None:
+    """Handle options update."""
+    await hass.config_entries.async_reload(entry.entry_id)
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+    """Unload a config entry."""
+    return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
diff --git a/homeassistant/components/google_cloud/config_flow.py b/homeassistant/components/google_cloud/config_flow.py
new file mode 100644
index 00000000000..bf97de67eb1
--- /dev/null
+++ b/homeassistant/components/google_cloud/config_flow.py
@@ -0,0 +1,169 @@
+"""Config flow for the Google Cloud integration."""
+
+from __future__ import annotations
+
+import json
+import logging
+from typing import TYPE_CHECKING, Any, cast
+
+from google.cloud import texttospeech
+import voluptuous as vol
+
+from homeassistant.components.file_upload import process_uploaded_file
+from homeassistant.components.tts import CONF_LANG
+from homeassistant.config_entries import (
+    ConfigEntry,
+    ConfigFlow,
+    ConfigFlowResult,
+    OptionsFlowWithConfigEntry,
+)
+from homeassistant.core import callback
+from homeassistant.helpers.selector import (
+    FileSelector,
+    FileSelectorConfig,
+    SelectSelector,
+    SelectSelectorConfig,
+    SelectSelectorMode,
+)
+
+from .const import CONF_KEY_FILE, CONF_SERVICE_ACCOUNT_INFO, DEFAULT_LANG, DOMAIN, TITLE
+from .helpers import (
+    async_tts_voices,
+    tts_options_schema,
+    tts_platform_schema,
+    validate_service_account_info,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+UPLOADED_KEY_FILE = "uploaded_key_file"
+
+STEP_USER_DATA_SCHEMA = vol.Schema(
+    {
+        vol.Required(UPLOADED_KEY_FILE): FileSelector(
+            FileSelectorConfig(accept=".json,application/json")
+        )
+    }
+)
+
+
+class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for Google Cloud integration."""
+
+    VERSION = 1
+
+    _name: str | None = None
+    entry: ConfigEntry | None = None
+    abort_reason: str | None = None
+
+    def _parse_uploaded_file(self, uploaded_file_id: str) -> dict[str, Any]:
+        """Read and parse an uploaded JSON file."""
+        with process_uploaded_file(self.hass, uploaded_file_id) as file_path:
+            contents = file_path.read_text()
+        return cast(dict[str, Any], json.loads(contents))
+
+    async def async_step_user(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Handle the initial step."""
+        errors: dict[str, Any] = {}
+        if user_input is not None:
+            try:
+                service_account_info = await self.hass.async_add_executor_job(
+                    self._parse_uploaded_file, user_input[UPLOADED_KEY_FILE]
+                )
+                validate_service_account_info(service_account_info)
+            except ValueError:
+                _LOGGER.exception("Reading uploaded JSON file failed")
+                errors["base"] = "invalid_file"
+            else:
+                data = {CONF_SERVICE_ACCOUNT_INFO: service_account_info}
+                if self.entry:
+                    if TYPE_CHECKING:
+                        assert self.abort_reason
+                    return self.async_update_reload_and_abort(
+                        self.entry, data=data, reason=self.abort_reason
+                    )
+                return self.async_create_entry(title=TITLE, data=data)
+        return self.async_show_form(
+            step_id="user",
+            data_schema=STEP_USER_DATA_SCHEMA,
+            errors=errors,
+            description_placeholders={
+                "url": "https://console.cloud.google.com/apis/credentials/serviceaccountkey"
+            },
+        )
+
+    async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
+        """Import Google Cloud configuration from YAML."""
+
+        def _read_key_file() -> dict[str, Any]:
+            with open(
+                self.hass.config.path(import_data[CONF_KEY_FILE]), encoding="utf8"
+            ) as f:
+                return cast(dict[str, Any], json.load(f))
+
+        service_account_info = await self.hass.async_add_executor_job(_read_key_file)
+        try:
+            validate_service_account_info(service_account_info)
+        except ValueError:
+            _LOGGER.exception("Reading credentials JSON file failed")
+            return self.async_abort(reason="invalid_file")
+        options = {
+            k: v for k, v in import_data.items() if k in tts_platform_schema().schema
+        }
+        options.pop(CONF_KEY_FILE)
+        _LOGGER.debug("Creating imported config entry with options: %s", options)
+        return self.async_create_entry(
+            title=TITLE,
+            data={CONF_SERVICE_ACCOUNT_INFO: service_account_info},
+            options=options,
+        )
+
+    @staticmethod
+    @callback
+    def async_get_options_flow(
+        config_entry: ConfigEntry,
+    ) -> GoogleCloudOptionsFlowHandler:
+        """Create the options flow."""
+        return GoogleCloudOptionsFlowHandler(config_entry)
+
+
+class GoogleCloudOptionsFlowHandler(OptionsFlowWithConfigEntry):
+    """Google Cloud options flow."""
+
+    async def async_step_init(
+        self, user_input: dict[str, Any] | None = None
+    ) -> ConfigFlowResult:
+        """Manage the options."""
+        if user_input is not None:
+            return self.async_create_entry(data=user_input)
+
+        service_account_info = self.config_entry.data[CONF_SERVICE_ACCOUNT_INFO]
+        client: texttospeech.TextToSpeechAsyncClient = (
+            texttospeech.TextToSpeechAsyncClient.from_service_account_info(
+                service_account_info
+            )
+        )
+        voices = await async_tts_voices(client)
+        return self.async_show_form(
+            step_id="init",
+            data_schema=self.add_suggested_values_to_schema(
+                vol.Schema(
+                    {
+                        vol.Optional(
+                            CONF_LANG,
+                            default=DEFAULT_LANG,
+                        ): SelectSelector(
+                            SelectSelectorConfig(
+                                mode=SelectSelectorMode.DROPDOWN, options=list(voices)
+                            )
+                        ),
+                        **tts_options_schema(
+                            self.options, voices, from_config_flow=True
+                        ).schema,
+                    }
+                ),
+                self.options,
+            ),
+        )
diff --git a/homeassistant/components/google_cloud/const.py b/homeassistant/components/google_cloud/const.py
index 0fbd5e78274..6a718bf35d3 100644
--- a/homeassistant/components/google_cloud/const.py
+++ b/homeassistant/components/google_cloud/const.py
@@ -2,6 +2,10 @@
 
 from __future__ import annotations
 
+DOMAIN = "google_cloud"
+TITLE = "Google Cloud"
+
+CONF_SERVICE_ACCOUNT_INFO = "service_account_info"
 CONF_KEY_FILE = "key_file"
 
 DEFAULT_LANG = "en-US"
diff --git a/homeassistant/components/google_cloud/helpers.py b/homeassistant/components/google_cloud/helpers.py
index 940bae709d8..3c614156132 100644
--- a/homeassistant/components/google_cloud/helpers.py
+++ b/homeassistant/components/google_cloud/helpers.py
@@ -2,11 +2,13 @@
 
 from __future__ import annotations
 
+from collections.abc import Mapping
 import functools
 import operator
 from typing import Any
 
 from google.cloud import texttospeech
+from google.oauth2.service_account import Credentials
 import voluptuous as vol
 
 from homeassistant.components.tts import CONF_LANG
@@ -52,14 +54,18 @@ async def async_tts_voices(
 def tts_options_schema(
     config_options: dict[str, Any],
     voices: dict[str, list[str]],
+    from_config_flow: bool = False,
 ) -> vol.Schema:
     """Return schema for TTS options with default values from config or constants."""
+    # If we are called from the config flow we want the defaults to be from constants
+    # to allow clearing the current value (passed as suggested_value) in the UI.
+    # If we aren't called from the config flow we want the defaults to be from the config.
+    defaults = {} if from_config_flow else config_options
     return vol.Schema(
         {
             vol.Optional(
                 CONF_GENDER,
-                description={"suggested_value": config_options.get(CONF_GENDER)},
-                default=config_options.get(
+                default=defaults.get(
                     CONF_GENDER,
                     texttospeech.SsmlVoiceGender.NEUTRAL.name,  # type: ignore[attr-defined]
                 ),
@@ -74,8 +80,7 @@ def tts_options_schema(
             ),
             vol.Optional(
                 CONF_VOICE,
-                description={"suggested_value": config_options.get(CONF_VOICE)},
-                default=config_options.get(CONF_VOICE, DEFAULT_VOICE),
+                default=defaults.get(CONF_VOICE, DEFAULT_VOICE),
             ): SelectSelector(
                 SelectSelectorConfig(
                     mode=SelectSelectorMode.DROPDOWN,
@@ -84,8 +89,7 @@ def tts_options_schema(
             ),
             vol.Optional(
                 CONF_ENCODING,
-                description={"suggested_value": config_options.get(CONF_ENCODING)},
-                default=config_options.get(
+                default=defaults.get(
                     CONF_ENCODING,
                     texttospeech.AudioEncoding.MP3.name,  # type: ignore[attr-defined]
                 ),
@@ -100,23 +104,19 @@ def tts_options_schema(
             ),
             vol.Optional(
                 CONF_SPEED,
-                description={"suggested_value": config_options.get(CONF_SPEED)},
-                default=config_options.get(CONF_SPEED, 1.0),
+                default=defaults.get(CONF_SPEED, 1.0),
             ): NumberSelector(NumberSelectorConfig(min=0.25, max=4.0, step=0.01)),
             vol.Optional(
                 CONF_PITCH,
-                description={"suggested_value": config_options.get(CONF_PITCH)},
-                default=config_options.get(CONF_PITCH, 0),
+                default=defaults.get(CONF_PITCH, 0),
             ): NumberSelector(NumberSelectorConfig(min=-20.0, max=20.0, step=0.1)),
             vol.Optional(
                 CONF_GAIN,
-                description={"suggested_value": config_options.get(CONF_GAIN)},
-                default=config_options.get(CONF_GAIN, 0),
+                default=defaults.get(CONF_GAIN, 0),
             ): NumberSelector(NumberSelectorConfig(min=-96.0, max=16.0, step=0.1)),
             vol.Optional(
                 CONF_PROFILES,
-                description={"suggested_value": config_options.get(CONF_PROFILES)},
-                default=config_options.get(CONF_PROFILES, []),
+                default=defaults.get(CONF_PROFILES, []),
             ): SelectSelector(
                 SelectSelectorConfig(
                     mode=SelectSelectorMode.DROPDOWN,
@@ -137,8 +137,7 @@ def tts_options_schema(
             ),
             vol.Optional(
                 CONF_TEXT_TYPE,
-                description={"suggested_value": config_options.get(CONF_TEXT_TYPE)},
-                default=config_options.get(CONF_TEXT_TYPE, "text"),
+                default=defaults.get(CONF_TEXT_TYPE, "text"),
             ): vol.All(
                 vol.Lower,
                 SelectSelector(
@@ -166,3 +165,16 @@ def tts_platform_schema() -> vol.Schema:
             ),
         }
     )
+
+
+def validate_service_account_info(info: Mapping[str, str]) -> None:
+    """Validate service account info.
+
+    Args:
+        info: The service account info in Google format.
+
+    Raises:
+        ValueError: If the info is not in the expected format.
+
+    """
+    Credentials.from_service_account_info(info)  # type:ignore[no-untyped-call]
diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json
index 052fa79eef4..d0dda80a870 100644
--- a/homeassistant/components/google_cloud/manifest.json
+++ b/homeassistant/components/google_cloud/manifest.json
@@ -1,8 +1,11 @@
 {
   "domain": "google_cloud",
-  "name": "Google Cloud Platform",
-  "codeowners": ["@lufton"],
+  "name": "Google Cloud",
+  "codeowners": ["@lufton", "@tronikos"],
+  "config_flow": true,
+  "dependencies": ["file_upload"],
   "documentation": "https://www.home-assistant.io/integrations/google_cloud",
+  "integration_type": "service",
   "iot_class": "cloud_push",
   "requirements": ["google-cloud-texttospeech==2.17.2"]
 }
diff --git a/homeassistant/components/google_cloud/strings.json b/homeassistant/components/google_cloud/strings.json
new file mode 100644
index 00000000000..0a0804005de
--- /dev/null
+++ b/homeassistant/components/google_cloud/strings.json
@@ -0,0 +1,32 @@
+{
+  "config": {
+    "step": {
+      "user": {
+        "description": "Upload your Google Cloud service account JSON file that you can create at {url}.",
+        "data": {
+          "uploaded_key_file": "Upload service account JSON file"
+        }
+      }
+    },
+    "error": {
+      "invalid_file": "Invalid service account JSON file"
+    }
+  },
+  "options": {
+    "step": {
+      "init": {
+        "data": {
+          "language": "Default language of the voice",
+          "gender": "Default gender of the voice",
+          "voice": "Default voice name (overrides language and gender)",
+          "encoding": "Default audio encoder",
+          "speed": "Default rate/speed of the voice",
+          "pitch": "Default pitch of the voice",
+          "gain": "Default volume gain (in dB) of the voice",
+          "profiles": "Default audio profiles",
+          "text_type": "Default text type"
+        }
+      }
+    }
+  }
+}
diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py
index 29f7e10a580..d65a743c015 100644
--- a/homeassistant/components/google_cloud/tts.py
+++ b/homeassistant/components/google_cloud/tts.py
@@ -1,10 +1,12 @@
 """Support for the Google Cloud TTS service."""
 
+from __future__ import annotations
+
 import logging
-import os
+from pathlib import Path
 from typing import Any, cast
 
-from google.api_core.exceptions import GoogleAPIError
+from google.api_core.exceptions import GoogleAPIError, Unauthenticated
 from google.cloud import texttospeech
 import voluptuous as vol
 
@@ -12,10 +14,14 @@ from homeassistant.components.tts import (
     CONF_LANG,
     PLATFORM_SCHEMA as TTS_PLATFORM_SCHEMA,
     Provider,
+    TextToSpeechEntity,
     TtsAudioType,
     Voice,
 )
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
 from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.entity_platform import AddEntitiesCallback
 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
 
 from .const import (
@@ -25,10 +31,12 @@ from .const import (
     CONF_KEY_FILE,
     CONF_PITCH,
     CONF_PROFILES,
+    CONF_SERVICE_ACCOUNT_INFO,
     CONF_SPEED,
     CONF_TEXT_TYPE,
     CONF_VOICE,
     DEFAULT_LANG,
+    DOMAIN,
 )
 from .helpers import async_tts_voices, tts_options_schema, tts_platform_schema
 
@@ -45,13 +53,20 @@ async def async_get_engine(
     """Set up Google Cloud TTS component."""
     if key_file := config.get(CONF_KEY_FILE):
         key_file = hass.config.path(key_file)
-        if not os.path.isfile(key_file):
+        if not Path(key_file).is_file():
             _LOGGER.error("File %s doesn't exist", key_file)
             return None
     if key_file:
         client = texttospeech.TextToSpeechAsyncClient.from_service_account_file(
             key_file
         )
+        if not hass.config_entries.async_entries(DOMAIN):
+            _LOGGER.debug("Creating config entry by importing: %s", config)
+            hass.async_create_task(
+                hass.config_entries.flow.async_init(
+                    DOMAIN, context={"source": SOURCE_IMPORT}, data=config
+                )
+            )
     else:
         client = texttospeech.TextToSpeechAsyncClient()
     try:
@@ -60,7 +75,6 @@ async def async_get_engine(
         _LOGGER.error("Error from calling list_voices: %s", err)
         return None
     return GoogleCloudTTSProvider(
-        hass,
         client,
         voices,
         config.get(CONF_LANG, DEFAULT_LANG),
@@ -68,20 +82,51 @@ async def async_get_engine(
     )
 
 
-class GoogleCloudTTSProvider(Provider):
-    """The Google Cloud TTS API provider."""
+async def async_setup_entry(
+    hass: HomeAssistant,
+    config_entry: ConfigEntry,
+    async_add_entities: AddEntitiesCallback,
+) -> None:
+    """Set up Google Cloud text-to-speech."""
+    service_account_info = config_entry.data[CONF_SERVICE_ACCOUNT_INFO]
+    client: texttospeech.TextToSpeechAsyncClient = (
+        texttospeech.TextToSpeechAsyncClient.from_service_account_info(
+            service_account_info
+        )
+    )
+    try:
+        voices = await async_tts_voices(client)
+    except GoogleAPIError as err:
+        _LOGGER.error("Error from calling list_voices: %s", err)
+        if isinstance(err, Unauthenticated):
+            config_entry.async_start_reauth(hass)
+        return
+    options_schema = tts_options_schema(dict(config_entry.options), voices)
+    language = config_entry.options.get(CONF_LANG, DEFAULT_LANG)
+    async_add_entities(
+        [
+            GoogleCloudTTSEntity(
+                config_entry,
+                client,
+                voices,
+                language,
+                options_schema,
+            )
+        ]
+    )
+
+
+class BaseGoogleCloudProvider:
+    """The Google Cloud TTS base provider."""
 
     def __init__(
         self,
-        hass: HomeAssistant,
         client: texttospeech.TextToSpeechAsyncClient,
         voices: dict[str, list[str]],
         language: str,
         options_schema: vol.Schema,
     ) -> None:
-        """Init Google Cloud TTS service."""
-        self.hass = hass
-        self.name = "Google Cloud TTS"
+        """Init Google Cloud TTS base provider."""
         self._client = client
         self._voices = voices
         self._language = language
@@ -114,7 +159,7 @@ class GoogleCloudTTSProvider(Provider):
             return None
         return [Voice(voice, voice) for voice in voices]
 
-    async def async_get_tts_audio(
+    async def _async_get_tts_audio(
         self,
         message: str,
         language: str,
@@ -155,11 +200,7 @@ class GoogleCloudTTSProvider(Provider):
             ),
         )
 
-        try:
-            response = await self._client.synthesize_speech(request, timeout=10)
-        except GoogleAPIError as err:
-            _LOGGER.error("Error occurred during Google Cloud TTS call: %s", err)
-            return None, None
+        response = await self._client.synthesize_speech(request, timeout=10)
 
         if encoding == texttospeech.AudioEncoding.MP3:
             extension = "mp3"
@@ -169,3 +210,64 @@ class GoogleCloudTTSProvider(Provider):
             extension = "wav"
 
         return extension, response.audio_content
+
+
+class GoogleCloudTTSEntity(BaseGoogleCloudProvider, TextToSpeechEntity):
+    """The Google Cloud TTS entity."""
+
+    def __init__(
+        self,
+        entry: ConfigEntry,
+        client: texttospeech.TextToSpeechAsyncClient,
+        voices: dict[str, list[str]],
+        language: str,
+        options_schema: vol.Schema,
+    ) -> None:
+        """Init Google Cloud TTS entity."""
+        super().__init__(client, voices, language, options_schema)
+        self._attr_unique_id = f"{entry.entry_id}-tts"
+        self._attr_name = entry.title
+        self._attr_device_info = dr.DeviceInfo(
+            identifiers={(DOMAIN, entry.entry_id)},
+            manufacturer="Google",
+            model="Cloud",
+            entry_type=dr.DeviceEntryType.SERVICE,
+        )
+        self._entry = entry
+
+    async def async_get_tts_audio(
+        self, message: str, language: str, options: dict[str, Any]
+    ) -> TtsAudioType:
+        """Load TTS from Google Cloud."""
+        try:
+            return await self._async_get_tts_audio(message, language, options)
+        except GoogleAPIError as err:
+            _LOGGER.error("Error occurred during Google Cloud TTS call: %s", err)
+            if isinstance(err, Unauthenticated):
+                self._entry.async_start_reauth(self.hass)
+            return None, None
+
+
+class GoogleCloudTTSProvider(BaseGoogleCloudProvider, Provider):
+    """The Google Cloud TTS API provider."""
+
+    def __init__(
+        self,
+        client: texttospeech.TextToSpeechAsyncClient,
+        voices: dict[str, list[str]],
+        language: str,
+        options_schema: vol.Schema,
+    ) -> None:
+        """Init Google Cloud TTS service."""
+        super().__init__(client, voices, language, options_schema)
+        self.name = "Google Cloud TTS"
+
+    async def async_get_tts_audio(
+        self, message: str, language: str, options: dict[str, Any]
+    ) -> TtsAudioType:
+        """Load TTS from Google Cloud."""
+        try:
+            return await self._async_get_tts_audio(message, language, options)
+        except GoogleAPIError as err:
+            _LOGGER.error("Error occurred during Google Cloud TTS call: %s", err)
+            return None, None
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 912df1aee0f..5f46cb1013e 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -222,6 +222,7 @@ FLOWS = {
         "goodwe",
         "google",
         "google_assistant_sdk",
+        "google_cloud",
         "google_generative_ai_conversation",
         "google_mail",
         "google_photos",
diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json
index 38958845782..e379851b37f 100644
--- a/homeassistant/generated/integrations.json
+++ b/homeassistant/generated/integrations.json
@@ -2251,10 +2251,10 @@
           "name": "Google Assistant SDK"
         },
         "google_cloud": {
-          "integration_type": "hub",
-          "config_flow": false,
+          "integration_type": "service",
+          "config_flow": true,
           "iot_class": "cloud_push",
-          "name": "Google Cloud Platform"
+          "name": "Google Cloud"
         },
         "google_domains": {
           "integration_type": "hub",
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index d1aa76a4950..8dc22562398 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -836,6 +836,9 @@ google-api-python-client==2.71.0
 # homeassistant.components.google_pubsub
 google-cloud-pubsub==2.23.0
 
+# homeassistant.components.google_cloud
+google-cloud-texttospeech==2.17.2
+
 # homeassistant.components.google_generative_ai_conversation
 google-generativeai==0.7.2
 
diff --git a/tests/components/google_cloud/__init__.py b/tests/components/google_cloud/__init__.py
new file mode 100644
index 00000000000..67e83b58c71
--- /dev/null
+++ b/tests/components/google_cloud/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Google Cloud integration."""
diff --git a/tests/components/google_cloud/conftest.py b/tests/components/google_cloud/conftest.py
new file mode 100644
index 00000000000..acde62144a9
--- /dev/null
+++ b/tests/components/google_cloud/conftest.py
@@ -0,0 +1,122 @@
+"""Tests helpers."""
+
+from collections.abc import Generator
+import json
+from pathlib import Path
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from google.cloud.texttospeech_v1.types import cloud_tts
+import pytest
+
+from homeassistant.components.google_cloud.const import (
+    CONF_SERVICE_ACCOUNT_INFO,
+    DOMAIN,
+)
+
+from tests.common import MockConfigEntry
+
+VALID_SERVICE_ACCOUNT_INFO = {
+    "type": "service_account",
+    "project_id": "my project id",
+    "private_key_id": "my private key if",
+    "private_key": "-----BEGIN PRIVATE KEY-----\nMIICdwIBADANBgkqhkiG9w0BAQEFAASCAmEwggJdAgEAAoGBAKYscIlwm7soDsHAz6L6YvUkCvkrX19rS6yeYOmovvhoK5WeYGWUsd8V72zmsyHB7XO94YgJVjvxfzn5K8bLePjFzwoSJjZvhBJ/ZQ05d8VmbvgyWUoPdG9oEa4fZ/lCYrXoaFdTot2xcJvrb/ZuiRl4s4eZpNeFYvVK/Am7UeFPAgMBAAECgYAUetOfzLYUudofvPCaKHu7tKZ5kQPfEa0w6BAPnBF1Mfl1JiDBRDMryFtKs6AOIAVwx00dY/Ex0BCbB3+Cr58H7t4NaPTJxCpmR09pK7o17B7xAdQv8+SynFNud9/5vQ5AEXMOLNwKiU7wpXT6Z7ZIibUBOR7ewsWgsHCDpN1iqQJBAOMODPTPSiQMwRAUHIc6GPleFSJnIz2PAoG3JOG9KFAL6RtIc19lob2ZXdbQdzKtjSkWo+O5W20WDNAl1k32h6MCQQC7W4ZCIY67mPbL6CxXfHjpSGF4Dr9VWJ7ZrKHr6XUoOIcEvsn/pHvWonjMdy93rQMSfOE8BKd/I1+GHRmNVgplAkAnSo4paxmsZVyfeKt7Jy2dMY+8tVZe17maUuQaAE7Sk00SgJYegwrbMYgQnWCTL39HBfj0dmYA2Zj8CCAuu6O7AkEAryFiYjaUAO9+4iNoL27+ZrFtypeeadyov7gKs0ZKaQpNyzW8A+Zwi7TbTeSqzic/E+z/bOa82q7p/6b7141xsQJBANCAcIwMcVb6KVCHlQbOtKspo5Eh4ZQi8bGl+IcwbQ6JSxeTx915IfAldgbuU047wOB04dYCFB2yLDiUGVXTifU=\n-----END PRIVATE KEY-----\n",
+    "client_email": "my client email",
+    "client_id": "my client id",
+    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
+    "token_uri": "https://oauth2.googleapis.com/token",
+    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
+    "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/service-account",
+    "universe_domain": "googleapis.com",
+}
+
+
+@pytest.fixture
+def create_google_credentials_json(tmp_path: Path) -> str:
+    """Create googlecredentials.json."""
+    file_path = tmp_path / "googlecredentials.json"
+    with open(file_path, "w", encoding="utf8") as f:
+        json.dump(VALID_SERVICE_ACCOUNT_INFO, f)
+    return str(file_path)
+
+
+@pytest.fixture
+def create_invalid_google_credentials_json(create_google_credentials_json: str) -> str:
+    """Create invalid googlecredentials.json."""
+    invalid_service_account_info = VALID_SERVICE_ACCOUNT_INFO.copy()
+    invalid_service_account_info.pop("client_email")
+    with open(create_google_credentials_json, "w", encoding="utf8") as f:
+        json.dump(invalid_service_account_info, f)
+    return create_google_credentials_json
+
+
+@pytest.fixture
+def mock_process_uploaded_file(
+    create_google_credentials_json: str,
+) -> Generator[MagicMock]:
+    """Mock upload certificate files."""
+    with patch(
+        "homeassistant.components.google_cloud.config_flow.process_uploaded_file",
+        return_value=Path(create_google_credentials_json),
+    ) as mock_upload:
+        yield mock_upload
+
+
+@pytest.fixture
+def mock_config_entry() -> MockConfigEntry:
+    """Return the default mocked config entry."""
+    return MockConfigEntry(
+        title="my Google Cloud title",
+        domain=DOMAIN,
+        data={CONF_SERVICE_ACCOUNT_INFO: VALID_SERVICE_ACCOUNT_INFO},
+    )
+
+
+@pytest.fixture
+def mock_api_tts() -> AsyncMock:
+    """Return a mocked TTS client."""
+    mock_client = AsyncMock()
+    mock_client.list_voices.return_value = cloud_tts.ListVoicesResponse(
+        voices=[
+            cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-A"),
+            cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-B"),
+            cloud_tts.Voice(language_codes=["el-GR"], name="el-GR-Standard-A"),
+        ]
+    )
+    return mock_client
+
+
+@pytest.fixture
+def mock_api_tts_from_service_account_info(
+    mock_api_tts: AsyncMock,
+) -> Generator[AsyncMock]:
+    """Return a mocked TTS client created with from_service_account_info."""
+    with (
+        patch(
+            "google.cloud.texttospeech.TextToSpeechAsyncClient.from_service_account_info",
+            return_value=mock_api_tts,
+        ),
+    ):
+        yield mock_api_tts
+
+
+@pytest.fixture
+def mock_api_tts_from_service_account_file(
+    mock_api_tts: AsyncMock,
+) -> Generator[AsyncMock]:
+    """Return a mocked TTS client created with from_service_account_file."""
+    with (
+        patch(
+            "google.cloud.texttospeech.TextToSpeechAsyncClient.from_service_account_file",
+            return_value=mock_api_tts,
+        ),
+    ):
+        yield mock_api_tts
+
+
+@pytest.fixture
+def mock_setup_entry() -> Generator[AsyncMock]:
+    """Override async_setup_entry."""
+    with patch(
+        "homeassistant.components.google_cloud.async_setup_entry", return_value=True
+    ) as mock_setup_entry:
+        yield mock_setup_entry
diff --git a/tests/components/google_cloud/test_config_flow.py b/tests/components/google_cloud/test_config_flow.py
new file mode 100644
index 00000000000..a5a51052e66
--- /dev/null
+++ b/tests/components/google_cloud/test_config_flow.py
@@ -0,0 +1,183 @@
+"""Test the Google Cloud config flow."""
+
+from unittest.mock import AsyncMock, MagicMock
+from uuid import uuid4
+
+from homeassistant import config_entries
+from homeassistant.components import tts
+from homeassistant.components.google_cloud.config_flow import UPLOADED_KEY_FILE
+from homeassistant.components.google_cloud.const import (
+    CONF_KEY_FILE,
+    CONF_SERVICE_ACCOUNT_INFO,
+    DOMAIN,
+)
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.const import CONF_PLATFORM
+from homeassistant.core import HomeAssistant
+from homeassistant.data_entry_flow import FlowResultType
+from homeassistant.setup import async_setup_component
+
+from .conftest import VALID_SERVICE_ACCOUNT_INFO
+
+from tests.common import MockConfigEntry
+
+
+async def test_user_flow_success(
+    hass: HomeAssistant,
+    mock_process_uploaded_file: MagicMock,
+    mock_setup_entry: AsyncMock,
+) -> None:
+    """Test user flow creates entry."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert not result["errors"]
+
+    uploaded_file = str(uuid4())
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        {UPLOADED_KEY_FILE: uploaded_file},
+    )
+
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert result["title"] == "Google Cloud"
+    assert result["data"] == {CONF_SERVICE_ACCOUNT_INFO: VALID_SERVICE_ACCOUNT_INFO}
+    mock_process_uploaded_file.assert_called_with(hass, uploaded_file)
+    assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_user_flow_missing_file(
+    hass: HomeAssistant,
+    mock_setup_entry: AsyncMock,
+) -> None:
+    """Test user flow when uploaded file is missing."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["errors"] == {}
+
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        {UPLOADED_KEY_FILE: str(uuid4())},
+    )
+
+    assert result["type"] is FlowResultType.FORM
+    assert result["errors"] == {"base": "invalid_file"}
+    assert len(mock_setup_entry.mock_calls) == 0
+
+
+async def test_user_flow_invalid_file(
+    hass: HomeAssistant,
+    create_invalid_google_credentials_json: str,
+    mock_process_uploaded_file: MagicMock,
+    mock_setup_entry: AsyncMock,
+) -> None:
+    """Test user flow when uploaded file is invalid."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": SOURCE_USER}
+    )
+    assert result["type"] is FlowResultType.FORM
+    assert result["errors"] == {}
+
+    uploaded_file = str(uuid4())
+    result = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        {UPLOADED_KEY_FILE: uploaded_file},
+    )
+
+    assert result["type"] is FlowResultType.FORM
+    assert result["errors"] == {"base": "invalid_file"}
+    mock_process_uploaded_file.assert_called_with(hass, uploaded_file)
+    assert len(mock_setup_entry.mock_calls) == 0
+
+
+async def test_import_flow(
+    hass: HomeAssistant,
+    create_google_credentials_json: str,
+    mock_api_tts_from_service_account_file: AsyncMock,
+    mock_api_tts_from_service_account_info: AsyncMock,
+) -> None:
+    """Test the import flow."""
+    assert not hass.config_entries.async_entries(DOMAIN)
+    assert await async_setup_component(
+        hass,
+        tts.DOMAIN,
+        {
+            tts.DOMAIN: {CONF_PLATFORM: DOMAIN}
+            | {CONF_KEY_FILE: create_google_credentials_json}
+        },
+    )
+    await hass.async_block_till_done()
+    assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+    config_entry = hass.config_entries.async_entries(DOMAIN)[0]
+    assert config_entry.state is config_entries.ConfigEntryState.LOADED
+
+
+async def test_import_flow_invalid_file(
+    hass: HomeAssistant,
+    create_invalid_google_credentials_json: str,
+    mock_api_tts_from_service_account_file: AsyncMock,
+) -> None:
+    """Test the import flow when the key file is invalid."""
+    assert not hass.config_entries.async_entries(DOMAIN)
+    assert await async_setup_component(
+        hass,
+        tts.DOMAIN,
+        {
+            tts.DOMAIN: {CONF_PLATFORM: DOMAIN}
+            | {CONF_KEY_FILE: create_invalid_google_credentials_json}
+        },
+    )
+    await hass.async_block_till_done()
+    assert not hass.config_entries.async_entries(DOMAIN)
+    assert mock_api_tts_from_service_account_file.list_voices.call_count == 1
+
+
+async def test_options_flow(
+    hass: HomeAssistant,
+    mock_config_entry: MockConfigEntry,
+    mock_api_tts_from_service_account_info: AsyncMock,
+) -> None:
+    """Test options flow."""
+    mock_config_entry.add_to_hass(hass)
+    await hass.config_entries.async_setup(mock_config_entry.entry_id)
+    assert mock_api_tts_from_service_account_info.list_voices.call_count == 1
+
+    assert mock_config_entry.options == {}
+
+    result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
+    assert result["type"] is FlowResultType.FORM
+    assert result["step_id"] == "init"
+    data_schema = result["data_schema"].schema
+    assert set(data_schema) == {
+        "language",
+        "gender",
+        "voice",
+        "encoding",
+        "speed",
+        "pitch",
+        "gain",
+        "profiles",
+        "text_type",
+    }
+    assert mock_api_tts_from_service_account_info.list_voices.call_count == 2
+
+    result = await hass.config_entries.options.async_configure(
+        result["flow_id"],
+        user_input={"language": "el-GR"},
+    )
+    assert result["type"] is FlowResultType.CREATE_ENTRY
+    assert mock_config_entry.options == {
+        "language": "el-GR",
+        "gender": "NEUTRAL",
+        "voice": "",
+        "encoding": "MP3",
+        "speed": 1.0,
+        "pitch": 0.0,
+        "gain": 0.0,
+        "profiles": [],
+        "text_type": "text",
+    }
+    assert mock_api_tts_from_service_account_info.list_voices.call_count == 3
-- 
GitLab