Введение

Firebase - это мощная платформа для разработки мобильных приложений, но стандартная документация описывает только базовый сценарий: одно приложение = один Firebase проект. В реальных проектах часто возникают более сложные требования:

  • Подключение к Firebase проекту, где ваше приложение не зарегистрировано

  • Переключение между средами: Development и Production

  • Миграция данных между разными Firebase проектами

  • Использование разных проектов для разных функциональных модулей

В этой статье мы глубоко погрузимся в технические детали работы с Firebase и рассмотрим:

  1. Как и почему можно использовать Firebase проект без регистрации Bundle ID

  2. Традиционные способы переключения Firebase конфигураций (compile-time)

  3. Динамическое переключение проектов во время работы приложения (runtime)

  4. Практический пример: миграция пользовательских данных между приложениями

  5. Безопасность и важные моменты

  6. Заключение

В процессе разработки мобильных приложений иногда возникает необходимость мигрировать/синхронизировать наши данные между разными версиями приложения или даже между разными приложениями. В нашем случае стояла задача перенести данные из старого приложения в новое, используя Firebase Firestore как промежуточное хранилище.

Когда у вас возникает такая потребность переноса данных через Firebase, вы просто создаёте один проект в Firebase и подключаете к нему два разных приложения. Теперь два разных приложения используют один проект и могут писать и читать в одну базу данных. Тогда у вас никаких проблем, всё понятно и можете сразу переходить к разделу №2.

Также часто одно приложение может использовать несколько Firebase проектов, чтобы, например, разделить собираемую аналитику и крашлитику для debug-сборок и prod-сборок.

У нас отчасти так и было: старое приложение использовало два Firebase проекта. Давайте старое приложение будем называть OldApp, а новое - NewApp. Кэп.

Так вот OldApp использовало два Firebase:

  • OldAppFB - проект для продакшен-сборок

  • SandboxFB - проект для дебаг-сборок.

NewApp тоже использовало два Firebase:

  • NewAppFB - проект для продакшен-сборок

  • SandboxFB - проект для дебаг-сборок.

Как видите, у нас два разных приложения уже было подключено к одному проекту. Однако это было дебаг-пространство. Необходимо было OldApp подключить дополнительно к третьему проекту - это к продакшен-проекту нового приложения NewAppFB, чтобы мы могли записывать данные в БД нового проекта.

Тут мы столкнулись с первой сложностью. По разным причинам Firebase может не позволить подключить приложение к дополнительному проекту. Или это может быть просто нецелесообразно ;) Плюс нам нужно было менять Firebase проект динамически в runtime, поэтому традиционные "статические" Compile-Time способы разделения сред и проектов нам не подходили.

Итак: главная сложность заключалась в том, что нам нужно было:

  • Использовать Firebase проект, в котором не зарегистрирован Bundle ID нашего приложения

  • Динамически переключаться между разными Firebase проектами во время работы приложения

Давайте углубимся в детали.

1. Можно ли использовать Firebase проект без регистрации в нём нашего Bundle ID?

Зачем нам это нужно?

Когда мы разрабатываем систему миграции данных между приложениями, возникает ключевой вопрос: можем ли мы подключиться к Firebase проекту, в котором наше приложение официально не зарегистрировано? На первый взгляд это кажется невозможным - Firebase ведь должен знать, какое приложение к нему обращается.

Однако, понимая внутреннее устройство Firebase SDK и механизмы аутентификации запросов, мы обнаружим, что для определённых сервисов (в первую очередь Firestore) Bundle ID вашего приложения не играет никакой роли. Это открывает возможности для реализации миграции данных без необходимости регистрировать каждое приложение в каждом Firebase проекте.

Для подключения нашего iOS приложения к Firebase проекту, используется GoogleService-Info.plist. Что такое этот GoogleService-Info.plist? Это конфигурационный файл, содержащий ключи для подключения к Firebase проекту. Давайте разберем, что на самом деле содержится в конфигурационном файле:

<!-- Упрощенная структура GoogleService-Info.plist -->
<plist>
<dict>
    <!-- Ключ для доступа к Google APIs -->
    <key>API_KEY</key>
    <string>AIzaSyC_XXXXXXXXXXXXXXXXXXXXXXXXXX</string>
    
    <!-- Идентификатор проекта в Google Cloud -->
    <key>PROJECT_ID</key>
    <string>my-firebase-project</string>
    
    <!-- Уникальный ID приложения в Firebase -->
    <key>GOOGLE_APP_ID</key>
    <string>1:123456789012:ios:abc123def456</string>
    
    <!-- Bundle ID приложения, зарегистрированного в Console -->
    <key>BUNDLE_ID</key>
    <string>com.company.registered.app</string>
    
    <!-- OAuth client ID для Google Sign-In -->
    <key>CLIENT_ID</key>
    <string>123456789012-abcdefg.apps.googleusercontent.com</string>
    
    <!-- URL базы данных Firestore/Realtime Database -->
    <key>DATABASE_URL</key>
    <string>https://my-firebase-project.firebaseio.com</string>
    
    <!-- URL Cloud Storage -->
    <key>STORAGE_BUCKET</key>
    <string>my-firebase-project.appspot.com</string>
</dict>
</plist>

Почему важно понимать структуру plist?

Каждое поле в GoogleService-Info.plist играет свою роль в процессе подключения к Firebase. Когда вы понимаете, какое поле за что отвечает, вы можете осознанно принимать решения о том, какие сервисы будут работать при использовании "чужого" конфигурационного файла.

Например, API_KEY и PROJECT_ID - это главные идентификаторы для построения URL запросов к серверам Firebase. А BUNDLE_ID - это лишь метаданные, которые проверяются только определёнными сервисами.

Нас интересуют в первую очередь PROJECT_ID, GOOGLE_APP_ID, BUNDLE_ID

PROJECT_ID - Идентификатор проекта

Что это:

  • Уникальное имя вашего Firebase проекта в глобальном пространстве Google Cloud

  • Используется для построения URL и endpoint'ов

Примеры использования:

// Firestore URL
"https://firestore.googleapis.com/v1/projects/{PROJECT_ID}/databases/(default)"

// Realtime Database URL  
"https://{PROJECT_ID}-default-rtdb.firebaseio.com/"

// Storage URL
"https://firebasestorage.googleapis.com/v0/b/{PROJECT_ID}.appspot.com"

Особенности:

  • Создается при создании Firebase проекта

  • Нельзя изменить после создания

  • Должен быть глобально уникальным

  • Один PROJECT_ID на весь проект

GOOGLE_APP_ID - Идентификатор приложения внутри проекта Firebase

Что это:

  • Уникальный идентификатор конкретного приложения (iOS, Android, Web) внутри конкретного Firebase проекта

  • Связывает аналитику, крэш-репорты и другие данные с конкретным приложением

Формат:

1:123456789012:ios:abc123def456
│ │ │ │
│ │ │ └── Хэш приложения (уникальный для платформы)
│ │ └── Платформа (ios, android, web)
│ └── Номер проекта Google Cloud
└── Версия формата (обычно 1)

Особенности:

  • Создается при регистрации приложения в Firebase проекте

  • Разный для каждого подключенного приложения (iOS, Android и Web) к одному проекту

  • Один проект может иметь несколько GOOGLE_APP_ID

Что это даёт на практике?

GOOGLE_APP_ID - это связующее звено между вашим приложением и Firebase проектом. Он генерируется при регистрации приложения в Firebase Console и является уникальным для каждой комбинации "проект + платформа + приложение".

Важно понимать: один Firebase проект может иметь несколько GOOGLE_APP_ID - по одному для каждого зарегистрированного приложения (iOS, Android, Web). При этом все они имеют доступ к одним и тем же данным в Firestore, одним и тем же пользователям в Authentication и т.д.

BUNDLE_ID - Идентификатор приложения (Bundle Identifier)

Что это:

  • идентификатор пакета - это уникальный строковый идентификатор, который однозначно определяет ваше приложение в экосистеме Apple (App Store, iOS, macOS).

  • это строка в формате reverse DNS notation (обратная DNS нотация), которая обычно выглядит так: com.yourcompany.yourapp

Какие поля действительно важны? Давайте разберем, что проверяет каждый Firebase сервис. Разберём прежде всего больше всего интересующий нас запрос в Firestore.

Как работает Firebase SDK

Инициализация Firebase

// При вызове FirebaseApp.configure() SDK делает:

// 1. Читает GoogleService-Info.plist
let options = FirebaseOptions(contentsOfFile: plistPath)

// 2. Создаёт HTTP клиент для Google API
let apiKey = options.apiKey
let projectId = options.projectId

// 3. Настраивает endpoints
let firestoreEndpoint = "https://firestore.googleapis.com/v1/projects/\(projectId)/databases/(default)"
let authEndpoint = "https://identitytoolkit.googleapis.com/v1"

// 4. ВСЁ! Bundle ID НЕ используется для подключения

Что происходит "под капотом"?

Когда вы вызываете FirebaseApp.configure(), SDK не устанавливает постоянное соединение с сервером и не проходит "рукопожатие". Вместо этого он просто сохраняет параметры из plist в память и готовит HTTP-клиент для будущих запросов.

Каждый последующий вызов к Firestore, Authentication или другому сервису - это отдельный HTTPS запрос к REST API Google. И именно содержимое этих запросов определяет, будет ли операция успешной.

Когда вы делаете запрос к Firestore:

let db = Firestore.firestore()
db.collection("users").document("user1").getDocument()

Под капотом происходит слдущее:

POST https://firestore.googleapis.com/v1/projects/{PROJECT_ID}/databases/(default)/documents:commit 
Headers: 
    X-Goog-Api-Key: AIzaSyC_XXXXXXXXXXXXXXX 
    X-Firebase-GMPID: 1:123456789012:ios:abc123def456 
    Authorization: Bearer ya29.xxxxxxxx 
Body: {...}

Проверяет:

- API_KEY - для аутентификации запросов к Google API

- PROJECT_ID - определяет базу данных

- GOOGLE_APP_ID - идентификатор приложения в проекте

НЕ проверяет:

- BUNDLE_ID - игнорируется!

Ключевой момент: Firebase SDK отправляет GOOGLE_APP_ID в заголовке X-Firebase-GMPID, но сервер Firestore использует его только для аналитики и логирования, а не для авторизации доступа. Доступ к данным контролируется через Security Rules и Authentication токены.

Вывод: Firestore работает с любым Bundle ID, если есть валидный GOOGLE_APP_ID из проекта.

Даже при использовании "чужого" Bundle ID, безопасность обеспечивается через Security Rules:

// Firestore Security Rules
rules_version = '1';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}

Это означает:

  • Приложение A (Bundle ID: com.company.oldapp) может писать данные

  • Приложение B (Bundle ID: com.company.newapp) может читать те же данные

  • Безопасность обеспечивается через Authentication, а не Bundle ID. Для этого вам достаточно включить анонимную аутентификацию в Firebase

Когда Bundle ID критичен?

Однако, есть сервисы, где Bundle ID важен и сервис без его совпадения просто не будет работать, или будет работать частично, или с ограничениями. Давайте посмотрим, где это важно:

  1. Регистрации APNs токена (Push-уведомления)

    // iOS отправляет APNs token на Firebase
    // Firebase проверяет: "Этот Bundle ID зарегистрирован в проекте?"
    Messaging.messaging().apnsToken = deviceToken
  2. OAuth провайдеры (Google Sign In, Apple Sign In)

    // При авторизации через Google/Apple проверяется:
    // "Этот Bundle ID разрешен для OAuth?"
    GIDSignIn.sharedInstance.signIn(with: config)
    • Apple Sign In - требует точное совпадение Bundle ID

    • Google Sign In - требует точное совпадение Bundle ID

    • однако Email/Password, Anonymous Auth работают с любым Bundle ID

  3. Dynamic Links

    // При создании deep link проверяется Bundle ID
    DynamicLinks.dynamicLinks().handleUniversalLink(url)

В итоге мы получаем такую картину допустимости несовпадения Bundle ID приложения и зарегистрированного Bundle ID в конфигурационном файле и работоспособности сервисов Firebase:

Сервис Firebase

Работает с чужим Bundle ID

Ограничения

Альтернативы

Firestore

Полностью

Нет

-

Realtime Database

Полностью

Нет

-

Authentication

Частично

Google/Apple Sign-In не работают

Custom Auth, Email/Password

Cloud Storage

Полностью

Нет

-

Remote Config

Полностью

Нет

-

Crashlytics

Частично

Отчеты привязываются к зарегистрированному app

Использовать собственный сбор ошибок

Analytics

Частично

Данные идут в зарегистрированное app

Google Analytics API

Cloud Messaging

Не работает

Требует точный Bundle ID + APNs

Собственные уведомления

Dynamic Links

Не работает

Проверяет Bundle ID

Universal Links

App Check

Не работает

Привязан к Bundle ID

Кастомная аутентификация

Почему одни сервисы требуют Bundle ID, а другие - нет?

Разница кроется в архитектуре каждого сервиса:

  • Firestore, Realtime Database, Cloud Storage - это "чистые" data-сервисы. Они проверяют только авторизацию пользователя через токен и соответствие Security Rules. Им всё равно, откуда пришёл запрос.

  • Cloud Messaging (Push-уведомления) - требует точный Bundle ID, потому что Apple Push Notification Service (APNs) привязывает токены устройств к конкретному Bundle ID. Если Bundle ID не совпадает, APNs просто не доставит push.

  • Google/Apple Sign-In - OAuth провайдеры настраиваются на конкретный Bundle ID в их консолях (Google Cloud Console, Apple Developer Portal). При несовпадении OAuth flow просто не запустится.

  • Analytics, Crashlytics - технически работают с любым Bundle ID, но данные агрегируются по зарегистрированному приложению. Вы увидите отчёты, но они будут привязаны не к вашему приложению в консоли.

Вывод для миграции: если вам нужен только Firestore для передачи данных - Bundle ID не критичен. Достаточно иметь валидный GOOGLE_APP_ID и настроенную анонимную авторизацию.

Итак, для нашей ситуации, где нам необходим только доступ до Firestore, и мы не можем подключить OldApp к тому же Firebase проекту NewAppFB, что и NewApp, вполне подходит вариант, в котором мы будем использовать один GoogleService-Info.plist от NewApp в проекте NewAppFB как для самого NewApp так и для OldApp, без регистрации OldApp в NewAppFB (получении отдельного GoogleService-Info.plist для OldApp в NewAppFB).

Важно! Когда это (отсутствие регистрации Bundle ID) НЕ работает?

Случай 1: Разные Google Cloud Projects

Если Firebase проекты созданы в разных Google Cloud аккаунтах:

Project A: Firebase Project (Organization: Company A)
Project B: Firebase Project (Organization: Company B)

Тогда API_KEY от Project A не будет работать для Project B, даже если подменить PROJECT_ID.

Случай 2: VPC Service Controls

Если Firebase проект использует VPC Service Controls:

// Может быть настроено ограничение по Bundle ID
{
  "allowedServices": ["firestore.googleapis.com"],
  "allowedBundleIds": ["com.company.newapp"]  // Только этот Bundle ID
}

Случай 3: App Check

Если включен Firebase App Check:

// App Check проверяет Bundle ID через Apple Device Check
let providerFactory = YourAppCheckProviderFactory()
AppCheck.setAppCheckProviderFactory(providerFactory)

Учитывайте эти особенности, при рассмотрении возможности использовать Firebase проект без регистрации в нём нашего Bundle ID

Итак, эти 3 принципиальных ограничения к нам не относились, поэтому мы решили первую проблему с подключением приложения к проекту.

Почему это работает:

  • REST API Firebase идентифицирует проект по PROJECT_ID и API_KEY

  • Авторизация запросов происходит через токены Authentication

  • BUNDLE_ID в plist - это метаданные для локального использования

Зачем нам это знание:

  • Мы можем использовать один GoogleService-Info.plist в нескольких приложениях

  • Не нужно регистрировать OldApp в проекте NewAppFB

  • Достаточно скопировать plist от NewApp в OldApp и получить доступ к Firestore

Теперь давайте углубимся во вторую проблему - это динамическое переключение между разными Firebase проектами во время работы приложения. Но сначала пробежимся, по традиционным вариантам переключения проектов.

2. Традиционные способы переключения Firebase проектов (Compile-Time)

Обычно в документации и в примерах используется статичные определения конфигурации Firebase во время компиляции.

Compile-time подходы - это основа, с которой стоит начинать. Они проще в реализации, надежнее и рекомендуются Google для большинства сценариев разделения сред.

  1. Xcode Configurations + Build Scripts
    Этот подход основан на конфигурациях сборки, которые используют разные GoogleService-Info.plist. Файлы хранятся в разных местах, с дефолтным именем GoogleService-Info.plist. При сборке нужный файл копируется скриптом в корень бандла.

    Шаг 1: Создаем конфигурации в Xcode.

    Для этого в Xcode создаётся две конфигурации сборки Project → Info → Configurations:
    - Debug (Development Firebase)
    - Production (Production Firebase)

    Шаг 2: Храним несколько GoogleService-Info.plist

    При таком подходе мы храним разные конфигурацонные файлы под одинаковым дефолтным именем, например так.

    Resources/
    ├── Development/
    │   └── GoogleService-Info.plist      # dev-project
    └── Production/
        └── GoogleService-Info.plist      # production-project

    Важно: что эти файлы НЕ добавляются в Target Membership, иначе будет ошибка компиляции из-за одинаковых имён файлов! Один из них (нужный) копируется скриптом при сборке.

    Шаг 3: Создание скрипта в Build Phase Script для копирования нужного plist

    Добавляем Run Script Phase перед компиляцией:

    # Определяем конфигурацию
    CONFIGURATION_FOLDER=""
    
    if [ "${CONFIGURATION}" == "Debug" ]; then
        CONFIGURATION_FOLDER="Development"
    else
        CONFIGURATION_FOLDER="Production"
    fi 
    
    # Путь к исходному файлу
    SOURCE_PLIST="${SRCROOT}/Resources/${CONFIGURATION_FOLDER}/GoogleService-Info.plist"
    
    # Путь назначения в бандле
    DEST_PLIST="${BUILT_PRODUCTS_DIR}/${PRODUCT_NAME}.app/GoogleService-Info.plist"
    
    # Проверяем существование
    if [ ! -f "$SOURCE_PLIST" ]; then
        echo "error: GoogleService-Info.plist not found at ${SOURCE_PLIST}"
        exit 1
    fi
    
    # Копируем файл
    cp "$SOURCE_PLIST" "$DEST_PLIST"
    
    echo "Copied GoogleService-Info.plist from ${CONFIGURATION_FOLDER}"


    Теперь при сборке скрипт будет копировать нужный файл конфигурации в корень банда и соответственно будет использоваться нужный Firebase проект.>

    Плюсы:

    • Простота и надежность

    • Рекомендовано Google

    Минусы:

    • Нельзя переключаться без пересборки

    • Нельзя использовать несколько проектов одновременно в одной сборке

    Это основной и самый распространенный Compile-time подход для переключения проектов Firebase. Редко, когда для Compile-time подхода используют другие варианты, но мы их кратко тоже рассмотрим.

  2. CocoaPods Configurations
    Если используете CocoaPods, можно настроить разные Firebase SDK для разных конфигураций:

    # Podfile
    
    target 'MyApp' do
      # Общие поды
      pod 'Alamofire'
      
      # Firebase только для определенных конфигураций
      pod 'Firebase/Core', :configurations => ['Debug', 'Production']
      pod 'Firebase/Firestore', :configurations => ['Debug', 'Production']
    end

    Но это не решает проблему динамического переключения проектов.

  3. Multiple Targets
    Этот подход подразумевает создание отдельных таргетов для каждого Firebase проекта, например:

    MyApp-Dev (Bundle ID: com.company.app.dev)
    MyApp-Prod (Bundle ID: com.company.app)
    MyApp-Migration (Bundle ID: com.company.app.migration)

    В чём суть подхода: каждый таргет имеет свой GoogleService-Info.plistи Bundle ID и у вас в каждом таргете можно сразу положить нужный конфигурационный файл в корень бандла (не будет конфликта и не нужно скриптов копирования) и далее использовать дефолтную инициализацию Firebase в коде, как в предыдущих вариантах.
    Такой подход в каких-то вариантах может быть подходящий, но так же и может быть проблемным из-за разных таргетов.

    Итак, ни один из вариантов Compile-Time подхода не позволяет нам динамически переключать проекты в коде. Однако, Firebase поддерживает создание нескольких экземпляров FirebaseApp одновременно

3. Динамическое переключение Firebase проектов (Runtime)

Все Compile-Time подходы, рассмотренные выше, имеют общий недостаток: Firebase проект «зашит» в сборку на этапе компиляции. А что если нам нужно:

  • Подключиться к другому Firebase проекту прямо во время работы приложения?

  • Работать с двумя или более Firebase проектами одновременно?

  • Дать пользователю возможность мигрировать данные из одного проекта в другой?

Именно такая задача стояла перед нами: OldApp должен был записать данные в NewAppFB (проект нового приложения), при этом продолжая использовать свой основной OldAppFB для аналитики и крашлитики.

Ключевая идея: отказ от стандартной инициализации

Стандартный подход Firebase предполагает, что вы вызываете FirebaseApp.configure() без параметров, и SDK автоматически ищет файл GoogleService-Info.plist в корне бандла. Но это ограничивает вас одним проектом на сборку.

Мы пошли другим путём:

  1. Храним все конфигурационные файлы с уникальными именами в бандле

  2. Никогда не используем стандартный FirebaseApp.configure() без параметров

  3. Всегда явно загружаем нужную конфигурацию из файла и передаём options

Это даёт нам полный контроль над тем, к какому Firebase проекту подключено приложение в данный момент.

Структура файлов конфигурации

Поскольку мы не используем стандартное имя GoogleService-Info.plist, все наши конфигурационные файлы имеют уникальные имена:

Resources/Firebase/
├── GoogleService-Info-Production.plist    # OldAppFB (продакшен)
├── GoogleService-Info-Sandbox.plist       # SandboxFB (дебаг)
└── GoogleService-Info-Migration.plist     # NewAppFB (для миграции данных)

Важно: Все эти файлы добавлены в Target Membership - конфликта не будет, так как имена разные. Никаких Build Script'ов для копирования не требуется!

Типы конфигураций

Определим enum для удобной работы с разными конфигурациями:

enum FBConfigurationType: String, CaseIterable {
    case production = "Production"
    case sandbox = "Sandbox"
    case migration = "Migration"
    
    // Имя plist-файла (без расширения)
    var googleInfoPlistName: String {
        "GoogleService-Info-\(self.rawValue)"
    }
    
    // Ожидаемый PROJECT_ID для валидации
    var expectedProjectID: String {
        switch self {
        case .production: "oldapp-production"   // OldAppFB
        case .sandbox: "shared-sandbox"         // SandboxFB
        case .migration: "newapp-production"    // NewAppFB
        }
    }
    
    // Человекочитаемое название
    var displayName: String {
        switch self {
        case .production: "Production (OldApp)"
        case .sandbox: "Sandbox (Debug)"
        case .migration: "Migration (NewApp)"
        }
    }
}

Как загружать конфигурацию из файла с уникальным именем?

Поскольку мы не используем стандартное имя, Firebase не найдёт конфигурацию автоматически. Нужно:

  1. Найти путь к файлу в бандле через Bundle.main.path(forResource:ofType:)

  2. Создать FirebaseOptions из этого файла

  3. Передать options в FirebaseApp.configure(options:)

func loadFirebaseConfig(type: FBConfigurationType) {
    // 1. Ищем путь к plist-файлу в бандле
    guard let configPath = Bundle.main.path(
        forResource: type.googleInfoPlistName,  // "GoogleService-Info-Production"
        ofType: "plist"
    ) else {
        NSLog("Config not found: \(type.googleInfoPlistName).plist")
        return
    }
    
    NSLog("Found config at: \(configPath)")
    
    // 2. Загружаем FirebaseOptions из файла
    guard let options = FirebaseOptions(contentsOfFile: configPath) else {
        NSLog("Failed to load Firebase options from: \(configPath)")
        return
    }
    
    // 3. Конфигурируем Firebase с явной передачей options
    FirebaseApp.configure(options: options)
    
    NSLog("Firebase configured with: \(type.displayName)")
}

Default App vs Named App

Firebase SDK поддерживает два типа приложений:

  1. Default App - создаётся через FirebaseApp.configure(options:) без имени

    • Доступен через FirebaseApp.app()

    • Используется по умолчанию всеми сервисами: Firestore.firestore(), Auth.auth() и т.д.

    • Может быть только один в приложении

  2. Named App - создаётся через FirebaseApp.configure(name:options:) с уникальным именем

    • Доступен через FirebaseApp.app(name: "имя")

    • Для работы нужно явно указывать app: Firestore.firestore(app: namedApp)

    • Можно создать несколько с разными именами

Наш подход:

  • Production и Sandbox - ис��ользуют default app (но с ручной загрузкой options!)

  • Migration - использует named app, чтобы можно было работать с двумя проектами одновременно

Почему Migration - это Named App?

Представьте ситуацию: приложение запущено с Production конфигурацией (Crashlytics шлёт репорты в OldAppFB). Пользователь нажимает «Мигрировать данные». Нам нужно:

  1. Продолжать отправлять крэш-репорты в OldAppFB (Production)

  2. Одновременно записывать данные в NewAppFB (Migration)

Если бы Migration был default app, нам пришлось бы удалить текущий default app - и Crashlytics перестал бы работать!

Поэтому для Migration мы создаём отдельный named app, который сосуществует с default app:

private func configureFirebaseApp(type: FBConfigurationType, options: FirebaseOptions) {
    if type == .migration {
        // Migration - создаём Named App (отдельно от default)
        FirebaseApp.configure(name: type.rawValue, options: options)
        NSLog("Migration Firebase configured as named app")
    } else {
        // Production/Sandbox - используем Default App
        FirebaseApp.configure(options: options)
        NSLog("\(type.rawValue) Firebase configured as default app")
    }
}

Получение FirebaseApp для нужной конфигурации

extension FBConfigurationType {
    /// Получить FirebaseApp для данной конфигурации
    var firebaseApp: FirebaseApp? {
        switch self {
        case .migration:
            // Named app - получаем по имени
           FirebaseApp.app(name: self.rawValue)
        case .production, .sandbox:
            // Default app
           FirebaseApp.app()
        }
    }
}

// Использование:
let migrationApp = FBConfigurationType.migration.firebaseApp
let productionApp = FBConfigurationType.production.firebaseApp

Переключение между конфигурациями

Когда нужно переключить default app (например, с Production на Sandbox), мы сталкиваемся с важным ограничением Firebase SDK: нельзя просто вызвать configure() повторно - это приведёт к крашу приложения с ошибкой «Default app has already been configured».

Поэтому процесс переключения требует предварительного удаления существующего приложения. Вот что важно учитывать:

1. Проверка необходимости переключения. Перед удалением текущей конфигурации нужно убедиться, что переключение действительно необходимо. Если PROJECT_ID текущего приложения совпадает с целевым - переключение не нужно, и мы экономим ресурсы.

2. Синхронное удаление. Метод FirebaseApp.delete() возвращает результат синхронно (несмотря на то, что есть версия с completion handler). Это означает, что после удаления можно сразу создавать новый app без ожидания.

3. Потеря состояния. При удалении FirebaseApp теряется всё связанное с ним состояние: авторизация пользователя, кэш Firestore, активные слушатели. Это нужно учитывать в логике приложения.

4. Разная логика для default и named app. Default app удаляется при несовпадении PROJECT_ID, а named app (Migration) удаляется всегда перед пересозданием, чтобы избежать конфликтов.

private func cleanupExistingConfigurations(for type: FBConfigurationType) {
    // Удаляем default app если Project ID не совпадает с новым
    // Это важно: если PROJECT_ID совпадает, переконфигурация не нужна
    if let defaultApp = FirebaseApp.app(),
       defaultApp.options.projectID != type.expectedProjectID {
        
        NSLog("Deleting existing default Firebase app (PROJECT_ID mismatch)")
        NSLog("Current: \(defaultApp.options.projectID ?? "nil")")
        NSLog("Target:  \(type.expectedProjectID)")
        _ = FirebaseApp.delete(defaultApp)
    }
    
    // Для Migration - удаляем старый named app если есть
    // Named apps независимы от default, поэтому удаляем по имени
    if type == .migration, let migrationApp = type.firebaseApp {
        NSLog("Deleting existing Migration Firebase app")
        _ = FirebaseApp.delete(migrationApp)
    }
}

Полный менеджер конфигураций

Теперь соберём всё вместе в единый менеджер. Почему именно такая архитектура?

Singleton-паттерн - менеджер должен быть единственным источником правды о текущей конфигурации Firebase. Если разные части приложения будут создавать свои экземпляры менеджера, мы потеряем контроль над состоянием.

Разделение ответственности - методы разбиты на мелкие функции, каждая из которых делает одну вещь: поиск файла, загрузка options, конфигурация app, валидация. Это упрощает отладку и тестирование.

Защита от повторной инициализации - метод loadFirebaseConfig сначала проверяет, не сконфигурировано ли уже приложение с нужным PROJECT_ID. Это предотвращает лишние операции и потенциальные крэши.

Валидация после конфигурации - после каждой конфигурации мы проверяем, что подключились к правильному проекту. В production-среде несовпадение PROJECT_ID - это критическая ошибка, которую нужно обнаружить как можно раньше.

Поддержка Objective-C - аннотация @objc позволяет использовать менеджер из legacy Objective-C кода, что важно для существующих проектов. Именно поэтому здесь мы также используем GCD, а не async/await. Если у вас не поддержки Objective-C кода, то эти аннотации можно удалить и, конечно, лучше использовать async/await.

import FirebaseCore
import FirebaseFirestore

@objc class FirebaseConfigurationManager: NSObject {
    
    // MARK: - Singleton
    // Единственный экземпляр менеджера для всего приложения
    @objc static let shared = FirebaseConfigurationManager()
    
    // MARK: - State
    // Текущая активная конфигурация
    private var currentConfiguration: FBConfigurationType = .sandbox
    // Флаг режима миграции (когда нужен доступ к NewAppFB)
    private var isMigrationModeEnabled: Bool = false
    
    // MARK: - Initialization
    // Private init гарантирует использование только через shared
    override private init() {
        super.init()
        loadInitialConfiguration()
    }
    
    // MARK: - Public API
    
    /// Возвращает текущую конфигурацию.
    /// Полезно для отображения в UI или логирования.
    func getCurrentConfiguration() -> FBConfigurationType {
        currentConfiguration
    }
    
    /// Переключает Firebase на указанную конфигурацию.
    /// Этот метод безопасен для повторного вызова - если конфигурация
    /// уже активна, ничего не произойдёт.
    func switchConfiguration(to configuration: FBConfigurationType) {
        currentConfiguration = configuration
        loadFirebaseConfig(type: configuration)
        NSLog("Switched to \(configuration.displayName)")
    }
    
    /// Включает или выключает режим миграции.
    /// При включении - активирует Migration конфигурацию.
    /// При выключении - возвращает конфигурацию по умолчанию для текущей сборки.
    @objc func setMigrationMode(_ enabled: Bool) {
        isMigrationModeEnabled = enabled
        
        if enabled {
            // Включаем Migration - создаём named app для записи в NewAppFB
            switchConfiguration(to: .migration)
        } else {
            // Выключаем - возвращаемся к основной конфигурации
            #if DEBUG
            switchConfiguration(to: .sandbox)
            #else
            switchConfiguration(to: .production)
            #endif
        }
    }
    
    // MARK: - Private: Initial Setup
    
    /// Определяет начальную конфигурацию на основе типа сборки.
    /// DEBUG сборки используют Sandbox (чтобы не засорять production аналитику),
    /// RELEASE сборки используют Production.
    private func loadInitialConfiguration() {
        #if DEBUG
        currentConfiguration = .sandbox
        #else
        currentConfiguration = .production
        #endif
    }
    
    // MARK: - Private: Firebase Configuration
    
    private func loadFirebaseConfig(type: FBConfigurationType) {
        // Шаг 1: Проверяем, не сконфигурировано ли уже правильно
        // Это оптимизация - избегаем лишних операций
        if let existingApp = type.firebaseApp,
           existingApp.options.projectID == type.expectedProjectID {
            NSLog("\(type.rawValue) Firebase already configured correctly")
            return
        }
        
        // Шаг 2: Очищаем старые конфигурации
        cleanupExistingConfigurations(for: type)
        
        // Шаг 3: Находим plist-файл в бандле
        guard let configPath = Bundle.main.path(
            forResource: type.googleInfoPlistName,
            ofType: "plist"
        ) else {
            NSLog("Config not found: \(type.googleInfoPlistName).plist")
            return
        }
        
        // Шаг 4: Загружаем FirebaseOptions
        guard let options = FirebaseOptions(contentsOfFile: configPath) else {
            NSLog("Failed to load options from: \(configPath)")
            return
        }
        
        // Шаг 5: Конфигурируем Firebase App
        configureFirebaseApp(type: type, options: options)
        
        // Шаг 6: Валидируем подключение
        validateConfiguration(type: type)
    }
    
    private func cleanupExistingConfigurations(for type: FBConfigurationType) {
        // Удаляем default app если Project ID не совпадает
        if let defaultApp = FirebaseApp.app(),
           defaultApp.options.projectID != type.expectedProjectID {
            _ = FirebaseApp.delete(defaultApp)
        }
        
        // Для Migration удаляем старый named app
        if type == .migration, let migrationApp = type.firebaseApp {
            _ = FirebaseApp.delete(migrationApp)
        }
    }
    
    private func configureFirebaseApp(type: FBConfigurationType, options: FirebaseOptions) {
        if type == .migration {
            // Named app для миграции - сосуществует с default app
            FirebaseApp.configure(name: type.rawValue, options: options)
            NSLog("Migration Firebase configured (named app)")
        } else {
            // Default app для Production/Sandbox
            FirebaseApp.configure(options: options)
            NSLog("\(type.rawValue) Firebase configured (default app)")
        }
    }
    
    private func validateConfiguration(type: FBConfigurationType) {
        guard let app = type.firebaseApp else {
            NSLog("Firebase app not found for \(type.rawValue)")
            return
        }
        
        let projectID = app.options.projectID ?? "unknown"
        if projectID == type.expectedProjectID {
            NSLog("Validated: \(type.rawValue) → \(projectID)")
        } else {
            // В production это критическая ошибка!
            NSLog("PROJECT_ID mismatch! Expected: \(type.expectedProjectID), Got: \(projectID)")
        }
    }
}

Инициализация в AppDelegate

// AppDelegate.swift

func application(_ application: UIApplication, 
                 didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    
    // Инициализируем Firebase
    let configManager = FirebaseConfigurationManager.shared
    let currentConfig = configManager.getCurrentConfiguration()
    
    NSLog("Configuring Firebase: \(currentConfig.displayName)")
    configManager.switchConfiguration(to: currentConfig)
    
    // Проверяем результат
    if let app = FirebaseApp.app() {
        NSLog("Firebase ready. Project: \(app.options.projectID ?? "unknown")")
    }
    
    return true
}

Работа с Migration Firestore

После включения режима миграции нам нужно получить доступ к Firestore нового проекта (NewAppFB). Здесь важно понимать разницу между default app и named app.

Когда вы вызываете Firestore.firestore() без параметров, вы получаете Firestore для default app. Но Migration - это named app, поэтому нужно явно указать, для какого приложения мы хотим получить Firestore.

Функция getMigrationFirestore() инкапсулирует эту логику и добавляет проверку: если Migration app не сконфигурирован (например, забыли вызвать setMigrationMode(true)), мы получим nil вместо краша приложения.

/// Получаем Firestore для миграции (отдельный проект!)
/// Важно: возвращает nil если Migration app не сконфигурирован
func getMigrationFirestore() -> Firestore? {
    // Получаем named app "Migration"
    guard let migrationApp = FBConfigurationType.migration.firebaseApp else {
        NSLog("Migration app not configured. Did you call setMigrationMode(true)?")
        return nil
    }
    // Создаём Firestore для этого конкретного app
    return Firestore.firestore(app: migrationApp)
}

// Использование:
// 1. Сначала включаем режим миграции
FirebaseConfigurationManager.shared.setMigrationMode(true)

// 2. Получаем Firestore для нового проекта
guard let migrationDb = getMigrationFirestore() else {
    showError("Не удалось подключиться к серверу миграции")
    return
}

// 3. Работаем с Firestore нового проекта
migrationDb.collection("users").document("user123").setData([
    "name": "John Doe",
    "migratedAt": FieldValue.serverTimestamp()
])

4. Практический пример: Миграция данных

Теперь соберём всё вместе и реализуем полноценный пример миграции данных из OldApp в NewApp через Firebase. Этот раздел покажет, как теория из предыдущих разделов применяется на практике.

Сценарий использования

Представим типичную ситуацию: компания выпускает новое приложение NewApp, и нужно дать пользователям возможность перенести свои данные из старого приложения OldApp.

Пользователь открывает OldApp, нажимает кнопку «Перенести данные в новое приложение», и его профиль, настройки и другие данные сохраняются в Firebase. Затем он открывает NewApp, авторизуется, и приложение автоматически загружает его данные.

Архитектура миграции

Процесс миграции состоит из нескольких этапов, которые выполняются в определённом порядке:

OldApp                        Firebase (NewAppFB)          NewApp
[Bundle: com.company.oldapp] [Project: newapp-production] [Bundle: com.company.newapp]
    │                                 │                                  │
    │  1. Собрать локальные данные    │                                  │
    │  2. Включить Migration mode     │                                  │
    │  3. Авторизоваться анонимно     │                                  │
    ├────────────────────────────────>│                                  │
    │  4. Записать данные в Firestore │                                  │
    ├────────────────────────────────>│                                  │
    │                                 │  5. Читать данные из Firestore   │
    │                                 │<─────────────────────────────────┤
    │                                 │  6. Импортировать в локальную БД │

Важно понимать: OldApp и NewApp никогда не взаимодействуют напрямую. Firebase Firestore выступает как промежуточное хранилище - «почтовый ящик», куда OldApp кладёт данные, а NewApp их забирает.

Структура данных в Firestore

Для миграции мы используем простую структуру - коллекцию users, где каждый документ представляет профиль пользователя:

users/
└── {user_id}/                          # Уникальный ID пользователя (email или внутренний ID)
    ├── name: "Иван Петров"             # Имя пользователя
    ├── email: "ivan@example.com"       # Email
    ├── avatarUrl: "https://..."        # URL аватара
    ├── role: "premium"                 # Роль или тип подписки
    ├── settings: {                     # Пользовательские настройки
    │   ├── theme: "dark"
    │   ├── language: "ru"
    │   └── notifications: true
    │   }
    ├── createdAt: Timestamp            # Дата регистрации (из OldApp)
    └── migratedAt: Timestamp           # Дата миграции

Это пример возможной структуры данных

  • Плоская иерархия - упрощает чтение и запись, меньше вложенных запросов

  • user_id как ключ документа - позволяет быстро найти данные пользователя

  • migratedAt - помогает отслеживать, когда данные были перенесены, и обрабатывать повторные миграции

Менеджер миграции данных

Менеджер миграции - это класс, который координирует весь процесс переноса данных. Он отвечает за:

  • Включение режима миграции (активация named app)

  • Анонимную авторизацию в Firebase (для доступа к Firestore)

  • Сбор локальных данных из OldApp

  • Запись данных в Firestore

Почему используется анонимная авторизация? Потому что мы не можем использовать Google Sign-In или Apple Sign-In с чужим Bundle ID (как обсуждалось в разделе 1). Анонимная авторизация работает без проверки Bundle ID и позволяет получить доступ к Firestore.

Почему Singleton? Миграция - это операция, которая не должна выполняться параллельно. Если пользователь случайно нажмёт кнопку дважды, повторная миграция будет заблокирована флагом isMigrationInProgress.

import FirebaseCore
import FirebaseFirestore
import FirebaseAuth

/// Ошибки миграции - каждый тип ошибки описывает конкретную проблему
enum MigrationError: LocalizedError {
    case firebaseNotConfigured      // Migration app не инициализирован
    case authenticationFailed       // Не удалось авторизоваться
    case noDataToMigrate            // Нет данных для переноса
    case firestoreError(Error)      // Ошибка при записи в Firestore
    
    var errorDescription: String? {
        switch self {
        case .firebaseNotConfigured: 
           "Сервис миграции не настроен. Обратитесь в поддержку."
        case .authenticationFailed: 
           "Не удалось подключиться к серверу. Проверьте интернет-соединение."
        case .noDataToMigrate: 
           "Нет данных для переноса."
        case .firestoreError(let error): 
           "Ошибка сохранения: \(error.localizedDescription)"
        }
    }
}


/// Менеджер миграции данных
class DataMigrationManager {
    
    // MARK: - Singleton
    static let shared = DataMigrationManager()
    
    // MARK: - Dependencies
    private let configManager = FirebaseConfigurationManager.shared
    
    // MARK: - State
    // Защита от параллельных миграций
    private var isMigrationInProgress = false
    
    private init() {}
    
    // MARK: - Public API
    
    /// Запускает процесс миграции данных.
    /// - Parameters:
    ///   - userId: Уникальный идентификатор пользователя (email или внутренний ID)
    ///   - completion: Callback с результатом операции
    func migrateData(
        userId: String, 
        completion: @escaping (Result<Void, MigrationError>) -> Void
    ) {
        // Защита от повторного вызова
        guard !isMigrationInProgress else {
            NSLog("Migration already in progress, ignoring duplicate call")
            return
        }
        
        isMigrationInProgress = true
        NSLog("Starting migration for user: \(userId)")
        
        // Шаг 1: Включаем режим миграции
        // Это создаёт named app "Migration", подключённый к NewAppFB
        configManager.setMigrationMode(true)
        
        // Шаг 2: Даём время на инициализацию Firebase
        // Firebase SDK требует небольшую паузу после configure()
        // для полной готовности всех сервисов
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
            self?.performMigration(userId: userId, completion: completion)
        }
    }
    
    // MARK: - Private: Migration Steps
    
    private func performMigration(userId: String, completion: @escaping (Result<Void, MigrationError>) -> Void) {
        // Получаем Firebase App для миграции
        guard let migrationApp = FBConfigurationType.migration.firebaseApp else {
            NSLog("Migration Firebase app not found")
            finish(with: .failure(.firebaseNotConfigured), completion: completion)
            return
        }
        
        // Создаём Auth и Firestore для Migration app
        // Важно: используем app-specific экземпляры, а не default
        let migrationAuth = Auth.auth(app: migrationApp)
        let migrationDb = Firestore.firestore(app: migrationApp)
        
        // Авторизуемся анонимно
        // Это необходимо для прохождения Security Rules (request.auth != null)
        migrationAuth.signInAnonymously { [weak self] result, error in
            if let error = error {
                NSLog("Anonymous auth failed: \(error.localizedDescription)")
                self?.finish(with: .failure(.authenticationFailed), completion: completion)
                return
            }
            
            NSLog("Authenticated anonymously: \(result?.user.uid ?? "unknown")")
            
            // Собираем локальные данные
            guard let userData = self?.collectLocalData() else {
                NSLog("No data to migrate")
                self?.finish(with: .failure(.noDataToMigrate), completion: completion)
                return
            }
            
            // Сохраняем в Firestore
            self?.saveToFirestore(
                db: migrationDb, 
                userId: userId, 
                data: userData, 
                completion: completion
            )
        }
    }
    
    /// Собирает данные пользователя из локального хранилища OldApp
    private func collectLocalData() -> [String: Any]? {
        var data: [String: Any] = [:]
        
        // Пример сбора данных, например из UserDefaults
        if let name = UserDefaults.standard.string(forKey: "userName") {
            data["name"] = name
        }
        if let email = UserDefaults.standard.string(forKey: "userEmail") {
            data["email"] = email
        }
        if let avatarUrl = UserDefaults.standard.string(forKey: "userAvatarUrl") {
            data["avatarUrl"] = avatarUrl
        }
        if let role = UserDefaults.standard.string(forKey: "userRole") {
            data["role"] = role
        }
        
        // Настройки
        let settings: [String: Any] = [
            "theme": UserDefaults.standard.string(forKey: "theme") ?? "light",
            "language": UserDefaults.standard.string(forKey: "language") ?? "ru",
            "notifications": UserDefaults.standard.bool(forKey: "notifications")
        ]
        data["settings"] = settings
        
        // Дата регистрации (если есть)
        if let createdTimestamp = UserDefaults.standard.object(forKey: "userCreatedAt") as? TimeInterval {
            data["createdAt"] = Date(timeIntervalSince1970: createdTimestamp)
        }
        
        return data.isEmpty ? nil : data
    }
    
    /// Сохраняет данные в Firestore
    private func saveToFirestore(
        db: Firestore,
        userId: String,
        data: [String: Any],
        completion: @escaping (Result<Void, MigrationError>) -> Void
    ) {
        // Формируем документ
        var document = data
        document["migratedAt"] = FieldValue.serverTimestamp()
        
        // Путь: users/{userId}
        let docRef = db.collection("users").document(userId)
        
        // Используем merge: true чтобы не перезаписать существующие данные
        // (если пользователь уже начал использовать NewApp)
        docRef.setData(document, merge: true) { [weak self] error in
            if let error = error {
                NSLog("Firestore write failed: \(error.localizedDescription)")
                self?.finish(with: .failure(.firestoreError(error)), completion: completion)
            } else {
                NSLog("Data migrated successfully!")
                self?.finish(with: .success(()), completion: completion)
            }
        }
    }
    
    /// Завершает миграцию и вызывает callback
    private func finish(
        with result: Result<Void, MigrationError>, 
        completion: @escaping (Result<Void, MigrationError>) -> Void
    ) {
        isMigrationInProgress = false
        
        // Вызываем completion на main thread для безопасного обновления UI
        DispatchQueue.main.async {
            completion(result)
        }
    }
}

Чтение данных в NewApp

Теперь посмотрим на другую сторону процесса - как NewApp читает мигрированные данные.

В NewApp ситуация проще: приложение работает со своим «родным» Firebase проектом (NewAppFB), поэтому нам не нужны named apps и специальные конфигурации. Мы просто используем стандартный Firestore.firestore().

Процесс импорта выглядит так:

  1. Пользователь авторизуется в NewApp

  2. Приложение запрашивает данные из коллекции users по userId

  3. Если данные найдены - применяем настройки к локальному хранилищу

  4. Показываем пользователю подтверждение успешного импорта

Важный момент: мы используем async/await для асинхронных операций с Firestore. Это делает код более читаемым и позволяет легко обрабатывать ошибки через try/catch.

import FirebaseFirestore

/// Класс для импорта данных в NewApp
class DataImporter {
    
    // Default Firestore - это уже NewAppFB, потому что NewApp
    // использует GoogleService-Info.plist от NewAppFB
    private let db = Firestore.firestore()
    
    /// Импортирует данные пользователя, мигрированные из OldApp
    /// - Parameter userId: Идентификатор пользователя (должен совпадать с тем, что использовался в OldApp)
    /// - Returns: Словарь с данными пользователя или nil если данные не найдены
    func importUserData(userId: String) async throws -> [String: Any]? {
        NSLog("Checking for migrated data for user: \(userId)")
        
        // Читаем документ пользователя
        let docRef = db.collection("users").document(userId)
        let snapshot = try await docRef.getDocument()
        
        // Проверяем, существует ли документ
        guard snapshot.exists, let data = snapshot.data() else {
            NSLog("No migrated data found for user: \(userId)")
            return nil
        }
        
        NSLog("Found migrated data, applying settings...")
        
        // Применяем данные к локальному хранилищу
        applyUserData(data)
        
        return data
    }
    
    /// Применяет импортированные данные к локальному хранилищу
    private func applyUserData(_ data: [String: Any]) {
        // Профиль пользователя
        if let name = data["name"] as? String {
            UserDefaults.standard.set(name, forKey: "userName")
            NSLog("Name: \(name)")
        }
        if let email = data["email"] as? String {
            UserDefaults.standard.set(email, forKey: "userEmail")
            NSLog("Email: \(email)")
        }
        if let avatarUrl = data["avatarUrl"] as? String {
            UserDefaults.standard.set(avatarUrl, forKey: "userAvatarUrl")
            NSLog("Avatar URL imported")
        }
        if let role = data["role"] as? String {
            UserDefaults.standard.set(role, forKey: "userRole")
            NSLog("Role: \(role)")
        }
        
        // Настройки
        if let settings = data["settings"] as? [String: Any] {
            if let theme = settings["theme"] as? String {
                UserDefaults.standard.set(theme, forKey: "theme")
                NSLog("Theme: \(theme)")
            }
            if let language = settings["language"] as? String {
                UserDefaults.standard.set(language, forKey: "language")
                NSLog("Language: \(language)")
            }
            if let notifications = settings["notifications"] as? Bool {
                UserDefaults.standard.set(notifications, forKey: "notifications")
                NSLog("Notifications: \(notifications)")
            }
        }
        
        NSLog("All settings imported successfully")
    }
}

// Пример использования в ViewController:
class ProfileViewController: UIViewController {
    
    private let importer = DataImporter()
    
    func checkForMigratedData() {
        // userId должен быть известен после авторизации
        guard let userId = getCurrentUserId() else { return }
        
        Task {
            do {
                if let data = try await importer.importUserData(userId: userId) {
                    // Показываем уведомление об успешном импорте
                    await MainActor.run {
                        showSuccessAlert("Данные из старого приложения успешно импортированы!")
                        // Обновляем UI с новыми данными
                        refreshUI()
                    }
                }
            } catch {
                NSLog("Import error: \(error)")
                // Не показываем ошибку пользователю - это не критично
            }
        }
    }
}

5. Безопасность и важные моменты

При работе с Firebase, особенно в сценарии миграции между приложениями, необходимо уделить особое внимание безопасности. В этом разделе рассмотрим ключевые аспекты.

Firestore Security Rules

Security Rules - это первая и самая важная линия защиты ваших данных в Firebase. Они выполняются на сервере Google, и их нельзя обойти с клиента.

Для нашего сценария миграции правила должны обеспечивать:

  1. Только авторизованные пользователи могут читать и писать данные

  2. Ограничение размера документа предотвращает злоупотребления

  3. Запрет доступа ко всему остальному - принцип минимальных привилегий

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /migration_data/{userId}/devices/{deviceId} {
      allow read, write: if request.auth != null;
      allow write: if request.resource.size() < 1048576; // 1MB limit
    }
    
    match /{document=**} {
      allow read, write: if false;
    }
  }
}

Почему request.auth != null достаточно?

В нашем сценарии мы используем анонимную авторизацию. Может показаться, что это небезопасно - ведь любой может авторизоваться анонимно. Но на практике это обеспечивает достаточный уровень защиты:

  1. Злоумышленнику нужно знать структуру вашей базы данных

  2. Нужно знать правильный userId для доступа к данным

  3. Анонимная авторизация всё равно создаёт сессию, которую можно отслеживать

  4. При необходимости можно добавить rate limiting через Cloud Functions

Авторизация - независимые сессии!

Это один из самых неочевидных и важных моментов при работе с несколькими Firebase Apps. Каждый FirebaseApp имеет полностью независимую сессию авторизации.

Что это значит на практике? Если пользователь авторизован в default app (Production), это никак не влияет на состояние авторизации в named app (Migration). Это два разных пользователя с точки зрения Firebase!

// Scenario: пользователь авторизован в Production app через Google Sign-In
// Default app (Production)
let productionUser = Auth.auth().currentUser
print(productionUser?.uid)    // "abc123" - реальный Google аккаунт
print(productionUser?.email)  // "user@gmail.com"

// Named app (Migration)
let migrationApp = FirebaseApp.app(name: "Migration")!
let migrationUser = Auth.auth(app: migrationApp).currentUser
print(migrationUser?.uid)     // nil - пользователь НЕ авторизован!

// После анонимной авторизации в Migration:
Auth.auth(app: migrationApp).signInAnonymously { result, error in
    print(result?.user.uid)   // "xyz789" - ДРУГОЙ пользователь!
    // Это анонимный пользователь, не связанный с Google аккаунтом
}

Почему это важно для миграции?

Мы не можем использовать Firebase UID для связывания данных между OldApp и NewApp, потому что:

  1. В OldApp пользователь авторизован в OldAppFB - у него один UID

  2. В Migration (NewAppFB) мы авторизуемся анонимно - совершенно другой UID

  3. В NewApp пользователь авторизуется заново - третий UID

Решение: используйте внешний идентификатор, который не зависит от Firebase:

  • Email пользователя

  • ID из вашей собственной системы авторизации

  • Phone number

  • Любой другой стабильный идентификатор

// Правильно: используем email как ключ документа
let userId = Auth.auth().currentUser?.email ?? "unknown"
db.collection("users").document(userId).setData(userData)

// Неправильно: Firebase UID разный в разных проектах!
let userId = Auth.auth().currentUser?.uid
db.collection("users").document(userId).setData(userData)

Дополнительные меры безопасности

1. Шифрование чувствительных данных

Если вы мигрируете пароли, токены или другие секреты, шифруйте их перед записью в Firestore:

import CryptoKit

// Шифруем пароль перед сохранением
let key = SymmetricKey(size: .bits256)
let encrypted = try AES.GCM.seal(password.data(using: .utf8)!, using: key)
userData["encryptedPassword"] = encrypted.combined?.base64EncodedString()

2. Ограничение времени жизни данных

Миграционные данные не должны храниться вечно. Настройте автоматическое удаление через Cloud Functions или TTL:

// Cloud Function для удаления старых данных
exports.cleanupOldMigrations = functions.pubsub
  .schedule('every 24 hours')
  .onRun(async (context) => {
    const cutoff = Date.now() - (30 * 24 * 60 * 60 * 1000); // 30 дней
    // Удаляем документы старше 30 дней
  });

3. Логирование и мониторинг

Отслеживайте аномальную активность в Firebase Console:

  • Необычно большое количество записей

  • Доступ к данным других пользователей

  • Ошибки авторизации

6. Заключение

Мы прошли большой путь - от понимания внутреннего устройства Firebase SDK до реализации полноценной системы миграции данных между приложениями. Давайте подведём итоги и выделим ключевые моменты.

Что мы узнали

1. Bundle ID - не барьер для Firestore

Главное открытие этой статьи: Firebase Firestore не проверяет Bundle ID вашего приложения при обращении к базе данных. Это означает, что вы можете использовать GoogleService-Info.plist от одного приложения в другом - и это будет работать.

Конечно, есть ограничения: Push-уведомления, Google Sign-In и некоторые другие сервисы требуют точного совпадения Bundle ID. Но для нашей задачи - миграции данных через Firestore - это не проблема.

2. Firebase поддерживает несколько приложений одновременно

Стандартная документация Firebase фокусируется на сценарии «одно приложение - один проект». Но SDK поддерживает создание нескольких экземпляров FirebaseApp с разными конфигурациями. Это открывает возможности для:

  • Работы с несколькими Firebase проектами из одного приложения

  • Миграции данных между проектами

  • A/B тестирования разных Firebase конфигураций

  • Разделения аналитики и данных между средами

3. Архитектура решения имеет значение

Правильная организация кода критически важна:

  • Default App для основной работы приложения (Crashlytics, Analytics, основной Firestore)

  • Named App для специальных задач (миграция в сторонний проект)

  • Централизованный менеджер для контроля состояния и предотвращения конфликтов

  • Валидация PROJECT_ID для раннего обнаружения ошибок конфигурации

4. Безопасность требует внимания

Даже в сценарии с анонимной авторизацией можно обеспечить достаточный уровень безопасности:

  • Security Rules на уровне Firestore

  • Ограничение размера документов

  • Использование внешних идентификаторов вместо Firebase UID

  • Шифрование чувствительных данных

Ключевые выводы

Тезис

Пояснение

Bundle ID не критичен для Firestore

Можно использовать plist от другого приложения для доступа к базе данных

Все plist с уникальными именами

Никаких скриптов копирования, все файлы в Target Membership, полный контроль

Default App для основной работы

Production/Sandbox переключаются через удаление и пересоздание

Named App для специальных задач

Migration существует параллельно с default app, не мешая основной работе

Независимые сессии авторизации

Каждый FirebaseApp - отдельный пользователь, используйте внешние ID

Security Rules обязательны

Они выполняются на сервере и обеспечивают реальную защиту

Когда использовать какой подход?

Задача

Рекомендуемый подход

Разделение Dev/Prod сред

Compile-Time (Build Scripts) - проще и надёжнее

Миграция данных между приложениями

Runtime с Named App - единственный вариант

Работа с двумя проектами одновременно

Default App + Named App

A/B тестирование Firebase

Runtime с переключением

Отладка Firebase подключения

Runtime с возможностью переключения в UI

Что дальше?

Если вы планируете реализовать подобную систему в своём проекте, вот несколько рекомендаций:

  1. Начните с простого - убедитесь, что базовое подключение к «чужому» Firebase проекту работает, прежде чем усложнять архитектуру

  2. Тестируйте Security Rules - используйте Firebase Emulator Suite для локального тестирования правил безопасности

  3. Логируйте всё - при работе с несколькими Firebase Apps легко запутаться, какой проект активен в данный момент

  4. Думайте о пользователе - миграция должна быть простой и понятной, без технических деталей в UI

Надеюсь, эта статья была полезной! Если у вас есть вопросы или вы хотите поделиться своим опытом работы с Firebase - пишите в комментариях.

Исходный код примеров из статьи доступен на GitHub

Автор: Сергей Волков
Дата: Декабрь 2025