Добавление встроенных покупок в ваше приложение Flutter

1. Введение

Добавление встроенных покупок в приложение Flutter требует правильной настройки магазинов App и Play, подтверждения покупки и предоставления необходимых разрешений, таких как привилегии подписки.

В этой лабораторной работе вы добавите три типа внутриигровых покупок в приложение (предоставленное вам) и подтвердите эти покупки с помощью бэкенда Dart в Firebase. Приложение Dash Clicker содержит игру, в которой в качестве валюты используется талисман Dash. Вам предстоит добавить следующие варианты покупок:

  1. Возможность многократной покупки сразу 2000 Dash.
  2. Единоразовая покупка обновления, превращающая Dash старого образца в Dash современного образца.
  3. Подписка, которая удваивает количество автоматически сгенерированных кликов.

Первый вариант покупки даёт пользователю прямую выгоду в размере 2000 Dash. Они доступны пользователю напрямую и могут быть куплены многократно. Это называется расходным материалом, поскольку он потребляется напрямую и может быть использован многократно.

Второй вариант позволяет улучшить Dash до более качественного варианта. Его нужно купить только один раз, и он будет доступен вечно. Такая покупка называется нерасходуемой, поскольку её нельзя использовать в приложении, но она действительна вечно.

Третий и последний вариант покупки — подписка. Пока подписка активна, пользователь будет получать Dash быстрее, но после прекращения оплаты подписки бонусы также исчезнут.

Бэкенд-сервис (также предоставляемый вам) работает как приложение Dart, проверяет факт совершения покупок и сохраняет их в Firestore. Firestore используется для упрощения процесса, но в вашем производственном приложении вы можете использовать любой тип бэкенд-сервиса.

300123416ebc8dc1.png7145d0fffe6ea741.png646317a79be08214.png

Что вы построите

  • Вы расширите приложение для поддержки покупок расходных материалов и подписок.
  • Вы также расширите внутреннее приложение Dart для проверки и хранения приобретенных товаров.

Чему вы научитесь

  • Как настроить App Store и Play Store для платных продуктов.
  • Как связаться с магазинами для проверки покупок и их хранения в Firestore.
  • Как управлять покупками в вашем приложении.

Что вам понадобится

  • Android Studio
  • Xcode (для разработки под iOS)
  • Flutter SDK

2. Настройте среду разработки

Чтобы начать эту практическую работу, загрузите код и измените идентификатор пакета на iOS и имя пакета на Android.

Загрузите код

Чтобы клонировать репозиторий GitHub из командной строки, используйте следующую команду:

git clone https://github.com/flutter/codelabs.git flutter-codelabs

Или, если у вас установлен cli-инструмент GitHub , используйте следующую команду:

gh repo clone flutter/codelabs flutter-codelabs

Пример кода клонирован в каталог flutter-codelabs , содержащий код для набора лабораторных работ. Код этой лабораторной работы находится в каталоге flutter-codelabs/in_app_purchases .

Структура каталога flutter-codelabs/in_app_purchases содержит ряд снимков текущего состояния, которое вы должны получить в конце каждого шага. Стартовый код находится на шаге 0, поэтому перейдите к нему следующим образом:

cd flutter-codelabs/in_app_purchases/step_00

Если вы хотите пропустить шаг вперед или посмотреть, как что-то должно выглядеть после шага, загляните в каталог, названный по имени интересующего вас шага. Код последнего шага находится в папке complete .

Настройте стартовый проект

Откройте стартовый проект из step_00/app в вашей любимой IDE. Для скриншотов мы использовали Android Studio, но Visual Studio Code тоже отличный вариант. В любом из редакторов убедитесь, что установлены последние версии плагинов Dart и Flutter.

Приложения, которые вы собираетесь создать, должны взаимодействовать с App Store и Play Store, чтобы знать, какие продукты доступны и по какой цене. Каждое приложение идентифицируется уникальным идентификатором. В App Store для iOS это называется идентификатором пакета, а в Play Store для Android — идентификатором приложения. Эти идентификаторы обычно создаются с использованием обратной записи доменного имени. Например, при создании приложения для покупки внутри приложения для flutter.dev вы будете использовать dev.flutter.inapppurchase . Подумайте об идентификаторе для своего приложения, который вы сейчас зададите в настройках проекта.

Сначала настройте идентификатор пакета для iOS. Для этого откройте файл Runner.xcworkspace в приложении Xcode.

a9fbac80a31e28e0.png

В структуре папок Xcode проект Runner находится наверху, а целевые объекты Flutter , Runner и Products — под ним. Дважды щёлкните по Runner , чтобы изменить настройки проекта, и нажмите «Signing & Capabilities» . Введите выбранный вами идентификатор пакета в поле «Team» , чтобы настроить команду.

812f919d965c649a.jpeg

Теперь вы можете закрыть Xcode и вернуться в Android Studio, чтобы завершить настройку для Android. Для этого откройте файл build.gradle.kts в папке android/app, и измените applicationId (в строке 24 на скриншоте ниже) на идентификатор приложения, совпадающий с идентификатором пакета iOS. Обратите внимание, что идентификаторы магазинов iOS и Android не обязательно должны быть идентичными, однако их идентичность снижает вероятность ошибок, поэтому в этой практической работе мы также будем использовать идентичные идентификаторы.

e320a49ff2068ac2.png

3. Установите плагин

В этой части лабораторной работы вы установите плагин in_app_purchase.

Добавить зависимость в pubspec

Добавьте in_app_purchase в pubspec, добавив in_app_purchase в зависимости вашего проекта:

$ cd app
$ flutter pub add in_app_purchase dev:in_app_purchase_platform_interface
Resolving dependencies... 
Downloading packages... 
  characters 1.4.0 (1.4.1 available)
  flutter_lints 5.0.0 (6.0.0 available)
+ in_app_purchase 3.2.3
+ in_app_purchase_android 0.4.0+3
+ in_app_purchase_platform_interface 1.4.0
+ in_app_purchase_storekit 0.4.4
+ json_annotation 4.9.0
  lints 5.1.1 (6.0.0 available)
  material_color_utilities 0.11.1 (0.13.0 available)
  meta 1.16.0 (1.17.0 available)
  provider 6.1.5 (6.1.5+1 available)
  test_api 0.7.6 (0.7.7 available)
Changed 5 dependencies!
7 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.

Откройте файл pubspec.yaml и убедитесь, что теперь in_app_purchase указан как запись в разделе dependencies , а in_app_purchase_platform_interface — в разделе dev_dependencies .

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  cloud_firestore: ^6.0.0
  cupertino_icons: ^1.0.8
  firebase_auth: ^6.0.1
  firebase_core: ^4.0.0
  google_sign_in: ^7.1.1
  http: ^1.5.0
  intl: ^0.20.2
  provider: ^6.1.5
  logging: ^1.3.0
  in_app_purchase: ^3.2.3

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  in_app_purchase_platform_interface: ^1.4.0

4. Настройте App Store

Чтобы настроить встроенные покупки и протестировать их на iOS, необходимо создать новое приложение в App Store и добавить в него доступные для покупки продукты. Вам не нужно ничего публиковать или отправлять приложение на проверку в Apple. Для этого вам понадобится учётная запись разработчика. Если у вас её нет, зарегистрируйтесь в программе разработчиков Apple .

Для использования встроенных покупок также необходимо иметь активное соглашение для платных приложений в App Store Connect. Перейдите по адресу https://appstoreconnect.apple.com/ и выберите «Соглашения, налоги и банковские операции» .

11db9fca823e7608.png

Здесь вы увидите соглашения для бесплатных и платных приложений. Бесплатные приложения должны иметь статус «Активно», а платные — «Новое». Убедитесь, что вы ознакомились с условиями, приняли их и ввели всю необходимую информацию.

74c73197472c9aec.png

Если всё настроено правильно, платные приложения будут иметь статус «Активно». Это очень важно, так как без действующего соглашения вы не сможете попробовать встроенные покупки.

4a100bbb8cafdbbf.jpeg

Зарегистрировать идентификатор приложения

Создайте новый идентификатор на портале разработчиков Apple. Перейдите на сайт developer.apple.com/account/resources/identifiers/list и нажмите на значок «плюс» рядом с заголовком « Идентификаторы» .

55d7e592d9a3fc7b.png

Выберите идентификаторы приложений

13f125598b72ca77.png

Выбрать приложение

41ac4c13404e2526.png

Введите описание и задайте идентификатор пакета так, чтобы он совпадал с тем же значением, которое было ранее установлено в XCode.

9d2c940ad80deeef.png

Дополнительные инструкции по созданию нового идентификатора приложения см. в справке по учетной записи разработчика .

Создание нового приложения

Создайте новое приложение в App Store Connect с вашим уникальным идентификатором пакета.

10509b17fbf031bd.png

5b7c0bb684ef52c7.png

Дополнительные инструкции по созданию нового приложения и управлению соглашениями см. в справке App Store Connect .

Для тестирования встроенных покупок в приложении вам понадобится тестовый пользователь в песочнице. Этот тестовый пользователь не должен быть подключен к iTunes — он используется только для тестирования встроенных покупок. Нельзя использовать адрес электронной почты, уже используемый для учётной записи Apple. В разделе «Пользователи и доступ» перейдите в раздел «Песочница» , чтобы создать новую учётную запись в песочнице или управлять существующими Apple ID в песочнице.

2ba0f599bcac9b36.png

Теперь вы можете настроить учетную запись пользователя «песочницы» на своем iPhone, перейдя в «Настройки» > «Разработчик» > «Учетная запись Apple «песочница»» .

74a545210b282ad8.pngeaa67752f2350f74.png

Настройка покупок в приложении

Теперь вам нужно настроить три покупаемых предмета:

  • dash_consumable_2k : расходуемый предмет, который можно приобретать многократно, предоставляя пользователю 2000 Dash (внутриигровая валюта) за покупку.
  • dash_upgrade_3d : нерасходуемая «обновляющая» покупка, которую можно приобрести только один раз, и которая дает пользователю внешне отличающуюся Dash для нажатия.
  • dash_subscription_doubler : подписка, которая предоставляет пользователю вдвое больше Dash за клик в течение срока действия подписки.

a118161fac83815a.png

Перейдите в раздел «Покупки внутри приложения» .

Создавайте покупки в приложении с указанными идентификаторами:

  1. Настройте dash_consumable_2k как расходный материал . Используйте dash_consumable_2k в качестве идентификатора продукта. Имя ссылки используется только в App Store Connect, просто укажите dash consumable 2k . 1f8527fc03902099.png Настройте доступность. Продукт должен быть доступен в стране пользователя песочницы. bd6b2ce2d9314e6e.png Добавьте цену и установите ее на уровне $1.99 или эквивалент в другой валюте. 926b03544ae044c4.png Добавьте свои локализации для покупки. Назовите покупку Spring is in the air и в описании 2000 dashes fly out . e26dd4f966dcfece.png Добавьте скриншот обзора. Содержание не имеет значения, если продукт не отправляется на обзор, но оно необходимо для того, чтобы продукт находился в состоянии «Готов к отправке», которое требуется, когда приложение получает товары из App Store. 25171bfd6f3a033a.png
  2. Настройте dash_upgrade_3d как Non-consumable (нерасходуемый) . Используйте dash_upgrade_3d в качестве идентификатора продукта. Задайте имя для ссылки dash upgrade 3d . Назовите покупку 3D Dash с описанием Brings your dash back to the future . Установите цену $0.99 . Настройте доступность и загрузите скриншот обзора так же, как для продукта dash_consumable_2k . 83878759f32a7d4a.png
  3. Настройте dash_subscription_doubler как автопродлеваемую подписку . Процесс оформления подписок немного отличается. Сначала необходимо создать группу подписок. Если в одну группу входит несколько подписок, пользователь может одновременно подписаться только на одну из них, но может повышать или понижать тариф между этими подписками. Назовите эту группу просто subscriptions . 393a44b09f3cd8bf.png И добавьте локализацию для группы подписки. 595aa910776349bd.png Далее вам нужно создать подписку. В поле «Имя ссылки» укажите dash subscription doubler , а в поле «Идентификатор продукта» — dash_subscription_doubler . 7bfff7bbe11c8eec.png Затем выберите продолжительность подписки (1 неделя) и локализации. Назовите эту подписку Jet Engine и опишите её как Doubles your clicks . Установите цену $0.49 . Настройте доступность и загрузите скриншот отзыва так же, как для продукта dash_consumable_2k . 44d18e02b926a334.png

Теперь вы должны увидеть продукты в списках:

17f242b5c1426b79.pngd71da951f595054a.png

5. Настройте Play Store.

Как и в случае с App Store, для Play Store вам понадобится учётная запись разработчика. Если у вас её ещё нет, зарегистрируйте её .

Создать новое приложение

Создайте новое приложение в Google Play Console:

  1. Откройте Play Console .
  2. Выберите Все приложения > Создать приложение.
  3. Выберите язык по умолчанию и добавьте название приложения. Введите название, которое будет отображаться в Google Play. Вы сможете изменить название позже.
  4. Укажите, что ваше приложение — игра. Вы сможете изменить это позже.
  5. Укажите, является ли ваше приложение бесплатным или платным.
  6. Заполните декларации о правилах содержания и экспортном законодательстве США.
  7. Выберите Создать приложение .

После создания приложения перейдите на панель управления и выполните все задачи в разделе «Настройка приложения» . Здесь вы можете указать некоторую информацию о приложении, например, рейтинги контента и скриншоты. 13845badcf9bc1db.png

Подписать заявку

Чтобы иметь возможность тестировать покупки внутри приложения, вам необходимо загрузить в Google Play хотя бы одну сборку.

Для этого необходимо, чтобы ваша сборка релиза была подписана чем-то, отличным от отладочных ключей.

Создать хранилище ключей

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

На Mac/Linux используйте следующую команду:

keytool -genkey -v -keystore ~/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias key

В Windows используйте следующую команду:

keytool -genkey -v -keystore c:\Users\USER_NAME\key.jks -storetype JKS -keyalg RSA -keysize 2048 -validity 10000 -alias key

Эта команда сохраняет файл key.jks в вашем домашнем каталоге. Если вы хотите сохранить файл в другом месте, измените аргумент, передаваемый параметру -keystore . Сохраните

keystore

файл конфиденциальный; не помещайте его в систему контроля версий!

Ссылка на хранилище ключей из приложения

Создайте файл с именем <your app dir>/android/key.properties , содержащий ссылку на ваше хранилище ключей:

storePassword=<password from previous step>
keyPassword=<password from previous step>
keyAlias=key
storeFile=<location of the key store file, such as /Users/<user name>/key.jks>

Настроить подпись в Gradle

Настройте подпись для своего приложения, отредактировав файл <your app dir>/android/app/build.gradle.kts .

Добавьте информацию о хранилище ключей из файла свойств перед блоком android :

import java.util.Properties
import java.io.FileInputStream

plugins {
    // omitted
}

val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
    keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}

android {
    // omitted
}

Загрузите файл key.properties в объект keystoreProperties .

Обновите блок buildTypes следующим образом:

   buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
        }
    }

Настройте блок signingConfigs в файле build.gradle.kts вашего модуля, используя информацию о конфигурации подписи:

   signingConfigs {
        create("release") {
            keyAlias = keystoreProperties["keyAlias"] as String
            keyPassword = keystoreProperties["keyPassword"] as String
            storeFile = keystoreProperties["storeFile"]?.let { file(it) }
            storePassword = keystoreProperties["storePassword"] as String
        }
    }

    buildTypes {
        release {
            signingConfig = signingConfigs.getByName("release")
        }
    }

Релизные сборки вашего приложения теперь будут подписываться автоматически.

Дополнительную информацию о подписании приложения см. в статье «Подписание приложения» на сайте developer.android.com .

Загрузите свою первую сборку

После того как ваше приложение настроено для подписи, вы сможете собрать его, выполнив:

flutter build appbundle

Эта команда по умолчанию генерирует сборку релиза, а выходные данные можно найти в <your app dir>/build/app/outputs/bundle/release/

На панели инструментов в консоли Google Play перейдите в раздел Тестирование и выпуск > Тестирование > Закрытое тестирование и создайте новый выпуск закрытого тестирования.

Затем загрузите пакет приложения app-release.aab сгенерированный командой сборки.

Нажмите «Сохранить» , а затем нажмите «Просмотреть релиз».

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

Настройте тестовых пользователей

Чтобы иметь возможность тестировать покупки внутри приложения, учетные записи Google ваших тестировщиков должны быть добавлены в консоль Google Play в двух местах:

  1. На конкретный тестовый трек (внутреннее тестирование)
  2. В качестве тестировщика лицензий

Для начала добавьте тестировщика в группу внутреннего тестирования. Вернитесь в раздел «Тестирование и выпуск» > «Тестирование» > «Внутреннее тестирование» и перейдите на вкладку «Тестировщики» .

a0d0394e85128f84.png

Создайте новый список адресов электронной почты, нажав «Создать список адресов электронной почты» . Присвойте списку имя и добавьте адреса электронной почты учётных записей Google, которым требуется доступ для тестирования покупок в приложении.

Далее установите флажок для списка и нажмите кнопку Сохранить изменения .

Затем добавьте тестировщиков лицензий:

  1. Вернитесь в раздел «Все приложения» консоли Google Play.
  2. Перейдите в Настройки > Проверка лицензии .
  3. Добавьте те же адреса электронной почты тестировщиков, которым необходимо будет тестировать покупки в приложении.
  4. Установите ответ лицензии на RESPOND_NORMALLY .
  5. Нажмите Сохранить изменения.

a1a0f9d3e55ea8da.png

Настройка покупок в приложении

Теперь вам нужно настроить товары, которые можно будет приобрести в приложении.

Как и в App Store, вам необходимо определить три разные покупки:

  • dash_consumable_2k : расходуемый предмет, который можно приобретать многократно, предоставляя пользователю 2000 Dash (внутриигровая валюта) за покупку.
  • dash_upgrade_3d : нерасходуемая «обновляющая» покупка, которую можно приобрести только один раз, что дает пользователю возможность кликнуть по внешнему виду отличающегося Dash.
  • dash_subscription_doubler : подписка, которая предоставляет пользователю вдвое больше Dash за клик в течение срока действия подписки.

Сначала добавьте расходные и нерасходные материалы.

  1. Перейдите в Google Play Console и выберите свое приложение.
  2. Перейдите в раздел Монетизация > Товары > Встроенные товары .
  3. Нажмите «Создать продукт». c8d66e32f57dee21.png
  4. Введите всю необходимую информацию о вашем продукте. Убедитесь, что идентификатор продукта точно соответствует идентификатору, который вы планируете использовать.
  5. Нажмите «Сохранить».
  6. Нажмите Активировать .
  7. Повторите процесс для покупки нерасходуемого «обновления».

Далее добавляем подписку:

  1. Перейдите в Google Play Console и выберите свое приложение.
  2. Перейдите в раздел Монетизация > Продукты > Подписки .
  3. Нажмите «Создать подписку». 32a6a9eefdb71dd0.png
  4. Введите всю необходимую информацию о вашей подписке. Убедитесь, что идентификатор продукта точно соответствует идентификатору, который вы планируете использовать.
  5. Нажмите «Сохранить».

Теперь ваши покупки должны быть настроены в Play Console.

6. Настройте Firebase

В этой лабораторной работе вы будете использовать внутреннюю службу для проверки и отслеживания покупок пользователей.

Использование бэкэнд-сервиса имеет ряд преимуществ:

  • Вы можете безопасно проверять транзакции.
  • Вы можете реагировать на события выставления счетов из магазинов приложений.
  • Вы можете отслеживать покупки в базе данных.
  • Пользователи не смогут обмануть ваше приложение и заставить его предоставлять премиум-функции, переведя свои системные часы.

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

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

Плагины Firebase также включены в стартовое приложение.

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

Создать проект Firebase

Перейдите в консоль Firebase и создайте новый проект Firebase. В этом примере назовите его Dash Clicker.

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

  1. На панели управления Firebase перейдите в раздел «Аутентификация» и включите ее при необходимости.
  2. Перейдите на вкладку «Способ входа» и включите поставщика входа Google .

fe2e0933d6810888.png

Поскольку вы также будете использовать базу данных Firestore от Firebases, включите и ее.

d02d641821c71e2c.png

Установите правила Cloud Firestore следующим образом:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /purchases/{purchaseId} {
      allow read: if request.auth != null && request.auth.uid == resource.data.userId
    }
  }
}

Настройка Firebase для Flutter

Рекомендуемый способ установки Firebase в приложении Flutter — использовать интерфейс командной строки FlutterFire. Следуйте инструкциям на странице установки .

При запуске настройки flutterfire выберите проект, который вы только что создали на предыдущем шаге.

$ flutterfire configure

i Found 5 Firebase projects.
? Select a Firebase project to configure your Flutter application with ›
❯ in-app-purchases-1234 (in-app-purchases-1234)
  other-flutter-codelab-1 (other-flutter-codelab-1)
  other-flutter-codelab-2 (other-flutter-codelab-2)
  other-flutter-codelab-3 (other-flutter-codelab-3)
  other-flutter-codelab-4 (other-flutter-codelab-4)
  <create a new project>

Затем включите iOS и Android , выбрав две платформы.

? Which platforms should your configuration support (use arrow keys & space to select)? ›
✔ android
✔ ios
  macos
  web

При появлении запроса на переопределение firebase_options.dart выберите «Да».

? Generated FirebaseOptions file lib/firebase_options.dart already exists, do you want to override it? (y/n) › yes

Настройка Firebase для Android: дальнейшие шаги

На панели управления Firebase перейдите в раздел «Обзор проекта», выберите «Настройки» и выберите вкладку «Общие» .

Прокрутите вниз до раздела «Ваши приложения» и выберите приложение dashclicker (android) .

b22d46a759c0c834.png

Чтобы разрешить вход в Google в режиме отладки, необходимо предоставить хэш-отпечаток SHA-1 вашего отладочного сертификата.

Получите хэш вашего отладочного сертификата подписи

В корне проекта приложения Flutter измените каталог на папку android/ а затем создайте отчет о подписи.

cd android
./gradlew :app:signingReport

Вам будет представлен большой список ключей подписи. Поскольку вам нужен хеш для отладочного сертификата, найдите сертификат со свойствами Variant и Config , установленными на debug . Скорее всего, хранилище ключей находится в вашей домашней папке по адресу .android/debug.keystore .

> Task :app:signingReport
Variant: debug
Config: debug
Store: /<USER_HOME_FOLDER>/.android/debug.keystore
Alias: AndroidDebugKey
MD5: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA1: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
SHA-256: XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX:XX
Valid until: Tuesday, January 19, 2038

Скопируйте хэш SHA-1 и заполните последнее поле в модальном диалоговом окне отправки приложения.

Наконец, снова запустите команду flutterfire configure , чтобы обновить приложение и включить в него конфигурацию подписи.

$ flutterfire configure
? You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the valus in your existing `firebase.json` file to configure your project? (y/n) › yes
✔ You have an existing `firebase.json` file and possibly already configured your project for Firebase. Would you prefer to reuse the values in your existing `firebase.json` file to configure your project? · yes

Настройка Firebase для iOS: дальнейшие шаги

Откройте файл ios/Runner.xcworkspace в Xcode или в предпочитаемой вами среде разработки.

В VSCode щелкните правой кнопкой мыши папку ios/ и затем open in xcode .

В Android Studio щелкните правой кнопкой мыши по папке ios/ , затем щелкните flutter а затем выберите опцию open iOS module in Xcode .

Чтобы разрешить вход через Google на iOS, добавьте параметр конфигурации CFBundleURLTypes в файлы plist сборки. (Дополнительную информацию см. в документации пакета google_sign_in .) В данном случае это файл ios/Runner/Info.plist .

Пара ключ-значение уже добавлена, но их значения необходимо заменить:

  1. Получите значение REVERSED_CLIENT_ID из файла GoogleService-Info.plist , без окружающего его элемента <string>..</string> .
  2. Замените значение в файле ios/Runner/Info.plist в ключе CFBundleURLTypes .
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <!-- TODO Replace this value: -->
            <!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
            <string>com.googleusercontent.apps.REDACTED</string>
        </array>
    </dict>
</array>

Настройка Firebase завершена.

7. Слушайте обновления о покупках

В этой части лабораторной работы вы подготовите приложение к покупке товаров. Этот процесс включает в себя прослушивание обновлений и ошибок покупки после запуска приложения.

Слушайте обновления о покупках

В main.dart, найдите виджет MyHomePage , содержащий Scaffold с BottomNavigationBar , содержащий две страницы. Эта страница также создаёт три Provider для DashCounter , DashUpgrades, и DashPurchases . DashCounter отслеживает текущее количество Dash и автоматически увеличивает его. DashUpgrades управляет обновлениями, которые можно приобрести за Dash. Эта практическая работа посвящена DashPurchases .

По умолчанию объект поставщика определяется при первом запросе. Этот объект прослушивает обновления покупок непосредственно при запуске приложения, поэтому отключите отложенную загрузку для этого объекта с помощью lazy: false :

lib/main.dart

ChangeNotifierProvider<DashPurchases>(
  create: (context) => DashPurchases(
    context.read<DashCounter>(),
  ),
  lazy: false,                                             // Add this line
),

Вам также понадобится экземпляр InAppPurchaseConnection . Однако, чтобы приложение оставалось тестируемым, необходимо каким-то образом имитировать это соединение. Для этого создайте метод экземпляра, который можно переопределить в тесте, и добавьте его в main.dart .

lib/main.dart

// Gives the option to override in tests.
class IAPConnection {
  static InAppPurchase? _instance;
  static set instance(InAppPurchase value) {
    _instance = value;
  }

  static InAppPurchase get instance {
    _instance ??= InAppPurchase.instance;
    return _instance!;
  }
}

Обновите тест следующим образом:

тест/widget_test.dart

import 'package:dashclicker/main.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_purchase/in_app_purchase.dart';     // Add this import
import 'package:in_app_purchase_platform_interface/src/in_app_purchase_platform_addition.dart'; // And this import

void main() {
  testWidgets('App starts', (tester) async {
    IAPConnection.instance = TestIAPConnection();          // Add this line
    await tester.pumpWidget(const MyApp());
    expect(find.text('Tim Sneath'), findsOneWidget);
  });
}

class TestIAPConnection implements InAppPurchase {         // Add from here
  @override
  Future<bool> buyConsumable({
    required PurchaseParam purchaseParam,
    bool autoConsume = true,
  }) {
    return Future.value(false);
  }

  @override
  Future<bool> buyNonConsumable({required PurchaseParam purchaseParam}) {
    return Future.value(false);
  }

  @override
  Future<void> completePurchase(PurchaseDetails purchase) {
    return Future.value();
  }

  @override
  Future<bool> isAvailable() {
    return Future.value(false);
  }

  @override
  Future<ProductDetailsResponse> queryProductDetails(Set<String> identifiers) {
    return Future.value(
      ProductDetailsResponse(productDetails: [], notFoundIDs: []),
    );
  }

  @override
  T getPlatformAddition<T extends InAppPurchasePlatformAddition?>() {
    // TODO: implement getPlatformAddition
    throw UnimplementedError();
  }

  @override
  Stream<List<PurchaseDetails>> get purchaseStream =>
      Stream.value(<PurchaseDetails>[]);

  @override
  Future<void> restorePurchases({String? applicationUserName}) {
    // TODO: implement restorePurchases
    throw UnimplementedError();
  }

  @override
  Future<String> countryCode() {
    // TODO: implement countryCode
    throw UnimplementedError();
  }
}                                                          // To here.

В файле lib/logic/dash_purchases.dart перейдите к коду DashPurchasesChangeNotifier . На этом этапе есть только DashCounter , который можно добавить к купленным Dash.

Добавьте свойство подписки на поток, _subscription (типа StreamSubscription<List<PurchaseDetails>> _subscription; ), экземпляр IAPConnection.instance, и импорт. Результирующий код должен выглядеть следующим образом:

lib/logic/dash_purchases.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';           // Add this import

import '../main.dart';                                           // And this import
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  StoreState storeState = StoreState.available;
  late StreamSubscription<List<PurchaseDetails>> _subscription;  // Add this line
  List<PurchasableProduct> products = [
    PurchasableProduct(
      'Spring is in the air',
      'Many dashes flying out from their nests',
      '\$0.99',
    ),
    PurchasableProduct(
      'Jet engine',
      'Doubles you clicks per second for a day',
      '\$1.99',
    ),
  ];

  bool get beautifiedDash => false;

  final iapConnection = IAPConnection.instance;                  // And this line

  DashPurchases(this.counter);

  Future<void> buy(PurchasableProduct product) async {
    product.status = ProductStatus.pending;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchased;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchasable;
    notifyListeners();
  }
}

Ключевое слово late добавляется к _subscription , поскольку _subscription инициализируется в конструкторе. Этот проект по умолчанию настроен как не допускающий значение NULL (NNBD), что означает, что свойства, не объявленные как допускающие значение NULL, должны иметь значение, отличное от NULL. Квалификатор late позволяет отложить определение этого значения.

В конструкторе получите поток purchaseUpdated и начните его прослушивать. В методе dispose() отмените подписку на поток.

lib/logic/dash_purchases.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:in_app_purchase/in_app_purchase.dart';

import '../main.dart';
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  StoreState storeState = StoreState.notAvailable;         // Modify this line
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [
    PurchasableProduct(
      'Spring is in the air',
      'Many dashes flying out from their nests',
      '\$0.99',
    ),
    PurchasableProduct(
      'Jet engine',
      'Doubles you clicks per second for a day',
      '\$1.99',
    ),
  ];

  bool get beautifiedDash => false;

  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter) {                            // Add from here
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
  }

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }                                                        // To here.

  Future<void> buy(PurchasableProduct product) async {
    product.status = ProductStatus.pending;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchased;
    notifyListeners();
    await Future<void>.delayed(const Duration(seconds: 5));
    product.status = ProductStatus.purchasable;
    notifyListeners();
  }
                                                           // Add from here
  void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
    // Handle purchases here
  }

  void _updateStreamOnDone() {
    _subscription.cancel();
  }

  void _updateStreamOnError(dynamic error) {
    //Handle error here
  }                                                        // To here.
}

Теперь приложение получает обновления о покупках, поэтому в следующем разделе вы совершите покупку!

Прежде чем продолжить, запустите тесты с « flutter test" чтобы убедиться, что все настроено правильно.

$ flutter test

00:01 +1: All tests passed!

8. Совершайте покупки

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

Адаптировать закупаемый продукт

PurchasableProduct отображает фиктивный товар. Обновите его, чтобы отображался реальный контент, заменив класс PurchasableProduct в purchasable_product.dart следующим кодом:

lib/model/purchasable_product.dart

import 'package:in_app_purchase/in_app_purchase.dart';

enum ProductStatus { purchasable, purchased, pending }

class PurchasableProduct {
  String get id => productDetails.id;
  String get title => productDetails.title;
  String get description => productDetails.description;
  String get price => productDetails.price;
  ProductStatus status;
  ProductDetails productDetails;

  PurchasableProduct(this.productDetails) : status = ProductStatus.purchasable;
}

В dash_purchases.dart, удалите фиктивные покупки и замените их пустым списком List<PurchasableProduct> products = []; .

Загрузить доступные покупки

Чтобы предоставить пользователю возможность совершить покупку, загрузите покупки из магазина. Сначала проверьте, доступен ли магазин. Если магазин недоступен, установка storeState в значение notAvailable выведет пользователю сообщение об ошибке.

lib/logic/dash_purchases.dart

  Future<void> loadPurchases() async {
    final available = await iapConnection.isAvailable();
    if (!available) {
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
  }

Когда магазин доступен, загрузите доступные покупки. Учитывая предыдущую настройку Google Play и App Store, ожидайте увидеть storeKeyConsumable , storeKeySubscription, и storeKeyUpgrade . Если ожидаемая покупка недоступна, выведите эту информацию на консоль; возможно, её также стоит отправить в бэкенд-сервис.

Метод await iapConnection.queryProductDetails(ids) возвращает как ненайденные идентификаторы, так и найденные доступные для покупки товары. Используйте productDetails из ответа для обновления пользовательского интерфейса и установите для StoreState значение available .

lib/logic/dash_purchases.dart

import '../constants.dart';

// ...

  Future<void> loadPurchases() async {
    final available = await iapConnection.isAvailable();
    if (!available) {
      storeState = StoreState.notAvailable;
      notifyListeners();
      return;
    }
    const ids = <String>{
      storeKeyConsumable,
      storeKeySubscription,
      storeKeyUpgrade,
    };
    final response = await iapConnection.queryProductDetails(ids);
    products = response.productDetails
        .map((e) => PurchasableProduct(e))
        .toList();
    storeState = StoreState.available;
    notifyListeners();
  }

Вызовите функцию loadPurchases() в конструкторе:

lib/logic/dash_purchases.dart

  DashPurchases(this.counter) {
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    loadPurchases();                                       // Add this line
  }

Наконец, измените значение поля storeState с StoreState.available на StoreState.loading:

lib/logic/dash_purchases.dart

StoreState storeState = StoreState.loading;

Показать доступные для покупки продукты

Рассмотрим файл purchase_page.dart . Виджет PurchasePage отображает _PurchasesLoading , _PurchaseList, или _PurchasesNotAvailable, в зависимости от StoreState . Виджет также отображает предыдущие покупки пользователя, которые используются на следующем этапе.

Виджет _PurchaseList отображает список доступных для покупки товаров и отправляет запрос на покупку объекту DashPurchases .

lib/pages/purchase_page.dart

class _PurchaseList extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    var purchases = context.watch<DashPurchases>();
    var products = purchases.products;
    return Column(
      children: products
          .map(
            (product) => _PurchaseWidget(
              product: product,
              onPressed: () {
                purchases.buy(product);
              },
            ),
          )
          .toList(),
    );
  }
}

Если настройки магазинов Android и iOS выполнены правильно, вы сможете увидеть доступные товары. Обратите внимание, что при вводе покупок в соответствующих консолях может пройти некоторое время.

ca1a9f97c21e552d.png

Вернитесь к dash_purchases.dart и реализуйте функцию покупки товара. Вам нужно только отделить расходные материалы от нерасходных. Товары для обновления и подписки не являются расходными материалами.

lib/logic/dash_purchases.dart

  Future<void> buy(PurchasableProduct product) async {
    final purchaseParam = PurchaseParam(productDetails: product.productDetails);
    switch (product.id) {
      case storeKeyConsumable:
        await iapConnection.buyConsumable(purchaseParam: purchaseParam);
      case storeKeySubscription:
      case storeKeyUpgrade:
        await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
      default:
        throw ArgumentError.value(
          product.productDetails,
          '${product.id} is not a known product',
        );
    }
  }

Прежде чем продолжить, создайте переменную _beautifiedDashUpgrade и обновите метод получения beautifiedDash , чтобы он ссылался на нее.

lib/logic/dash_purchases.dart

  bool get beautifiedDash => _beautifiedDashUpgrade;
  bool _beautifiedDashUpgrade = false;

Метод _onPurchaseUpdate получает обновления о покупке, обновляет статус товара, отображаемый на странице покупки, и применяет покупку к логике счётчика. Важно вызвать completePurchase после обработки покупки, чтобы магазин был уверен, что покупка обработана правильно.

lib/logic/dash_purchases.dart

  Future<void> _onPurchaseUpdate(
    List<PurchaseDetails> purchaseDetailsList,
  ) async {
    for (var purchaseDetails in purchaseDetailsList) {
      await _handlePurchase(purchaseDetails);
    }
    notifyListeners();
  }

  Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
    if (purchaseDetails.status == PurchaseStatus.purchased) {
      switch (purchaseDetails.productID) {
        case storeKeySubscription:
          counter.applyPaidMultiplier();
        case storeKeyConsumable:
          counter.addBoughtDashes(2000);
        case storeKeyUpgrade:
          _beautifiedDashUpgrade = true;
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      await iapConnection.completePurchase(purchaseDetails);
    }
  }

9. Настройте бэкэнд

Прежде чем приступать к отслеживанию и проверке покупок, настройте серверную часть Dart для поддержки этой функции.

В этом разделе работайте из папки dart-backend/ как из корня.

Убедитесь, что у вас установлены следующие инструменты:

Обзор базового проекта

Поскольку некоторые части проекта выходят за рамки данной лабораторной работы, они включены в начальный код. Рекомендуется ознакомиться с тем, что уже есть в начальном коде, прежде чем начать, чтобы понять, как вы собираетесь всё структурировать.

Этот бэкенд-код может работать локально на вашем компьютере, вам не нужно его развёртывать для использования. Однако вам необходимо подключиться с вашего устройства разработки (Android или iPhone) к компьютеру, на котором будет запущен сервер. Для этого они должны находиться в одной сети, и вам нужно знать IP-адрес вашего компьютера.

Попробуйте запустить сервер с помощью следующей команды:

$ dart ./bin/server.dart

Serving at http://0.0.0.0:8080

Бэкенд Dart использует shelf и shelf_router для обслуживания конечных точек API. По умолчанию сервер не предоставляет маршруты. Позже вы создадите маршрут для обработки процесса подтверждения покупки.

Один из компонентов, уже включённых в начальный код, — это IapRepository в lib/iap_repository.dart . Поскольку изучение взаимодействия с Firestore и базами данных в целом не рассматривается в рамках данной практической работы, начальный код содержит функции для создания и обновления покупок в Firestore, а также все классы для этих покупок.

Настройте доступ к Firebase

Для доступа к Firebase Firestore вам потребуется ключ доступа к сервисной учётной записи. Сгенерируйте его, открыв настройки проекта Firebase, перейдите в раздел «Сервисные учётные записи» и выберите «Сгенерировать новый закрытый ключ» .

27590fc77ae94ad4.png

Скопируйте загруженный JSON-файл в папку assets/ и переименуйте его в service-account-firebase.json .

Настройте доступ к Google Play

Чтобы получить доступ к Play Store для проверки покупок, необходимо создать учетную запись службы с этими разрешениями и загрузить для нее учетные данные JSON.

  1. Посетите страницу API разработчиков Android для Google Play в консоли Google Cloud. 629f0bd8e6b50be8.png Если Google Play Console предлагает вам создать проект или подключиться к существующему, сначала сделайте это, а затем вернитесь на эту страницу.
  2. Далее перейдите на страницу Учетные записи служб и нажмите + Создать учетную запись службы . 8dc97e3b1262328a.png
  3. Введите имя учетной записи службы и нажмите «Создать и продолжить» . 4fe8106af85ce75f.png
  4. Выберите роль подписчика Pub/Sub и нажмите Готово . a5b6fa6ea8ee22d.png
  5. После создания учетной записи перейдите в раздел «Управление ключами» . eb36da2c1ad6dd06.png
  6. Выберите Добавить ключ > Создать новый ключ . e92db9557a28a479.png
  7. Создайте и загрузите JSON-ключ. 711d04f2f4176333.png
  8. Переименуйте загруженный файл в service-account-google-play.json, и переместите его в каталог assets/ .
  9. Далее перейдите на страницу «Пользователи и разрешения» в Play Console. 28fffbfc35b45f97.png
  10. Нажмите « Пригласить новых пользователей» и введите адрес электронной почты ранее созданной учётной записи сервиса. Вы можете найти его в таблице на странице «Учётные записи сервисов». e3310cc077f397d.png
  11. Предоставьте приложению разрешения на просмотр финансовых данных и управление заказами и подписками . a3b8cf2b660d1900.png
  12. Нажмите Пригласить пользователя .

Еще одна вещь, которую нам нужно сделать, — это открыть lib/constants.dart, и заменить значение androidPackageId на идентификатор пакета, который вы выбрали для своего приложения Android.

Настройте доступ к Apple App Store

Чтобы получить доступ к App Store для проверки покупок, вам необходимо настроить общий секрет:

  1. Откройте App Store Connect .
  2. Перейдите в «Мои приложения» и выберите свое приложение.
  3. В боковой панели навигации выберите «Основные» > «Информация о приложении» .
  4. Нажмите «Управление» под заголовком «Общий секрет для конкретного приложения» . ad419782c5fbacb2.png
  5. Создайте новый секрет и скопируйте его. b5b72a357459b0e5.png
  6. Откройте lib/constants.dart, и замените значение appStoreSharedSecret только что созданным общим секретом.

Конфигурационный файл констант

Прежде чем продолжить, убедитесь, что в файле lib/constants.dart настроены следующие константы:

  • androidPackageId : идентификатор пакета, используемый в Android, например com.example.dashclicker
  • appStoreSharedSecret : общий секрет для доступа к App Store Connect для проверки покупки.
  • bundleId : идентификатор пакета, используемый в iOS, например com.example.dashclicker

Остальные константы вы можете пока игнорировать.

10. Подтверждайте покупки

Общий процесс проверки покупок аналогичен для iOS и Android.

В обоих магазинах ваше приложение получает токен при совершении покупки.

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

Затем серверная служба может сохранить покупку и ответить приложению, была ли покупка действительна или нет.

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

Настройте сторону Flutter

Настроить аутентификацию

Поскольку вы собираетесь отправлять покупки в свою серверную службу, вам необходимо убедиться, что пользователь аутентифицирован при совершении покупки. Большая часть логики аутентификации уже добавлена в стартовом проекте, вам просто нужно убедиться, что PurchasePage отображает кнопку входа в систему, когда пользователь еще не вошел в систему. Добавьте следующий код в начало метода сборки PurchasePage :

lib/pages/purchase_page.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../logic/dash_purchases.dart';
import '../logic/firebase_notifier.dart';                  // Add this import
import '../model/firebase_state.dart';                     // And this import
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import '../repo/iap_repo.dart';
import 'login_page.dart';                                  // And this one as well

class PurchasePage extends StatelessWidget {
  const PurchasePage({super.key});

  @override
  Widget build(BuildContext context) {                     // Update from here
    var firebaseNotifier = context.watch<FirebaseNotifier>();
    if (firebaseNotifier.state == FirebaseState.loading) {
      return _PurchasesLoading();
    } else if (firebaseNotifier.state == FirebaseState.notAvailable) {
      return _PurchasesNotAvailable();
    }

    if (!firebaseNotifier.loggedIn) {
      return const LoginPage();
    }                                                      // To here.

    // ...

Конечная точка проверки вызова из приложения

В приложении создайте функцию _verifyPurchase(PurchaseDetails purchaseDetails) , которая вызывает конечную точку /verifypurchase на вашем сервере Dart с помощью почтового вызова HTTP.

Отправьте выбранный магазин ( google_play для Play Store или app_store для App Store), serverVerificationData и productID . Сервер возвращает код состояния, указывающий, подтверждена ли покупка.

В константах приложения настройте IP-адрес сервера на IP-адрес вашего локального компьютера.

lib/logic/dash_purchases.dart

import 'dart:async';
import 'dart:convert';                                     // Add this import

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;                   // And this import
import 'package:in_app_purchase/in_app_purchase.dart';

import '../constants.dart';
import '../main.dart';
import '../model/purchasable_product.dart';
import '../model/store_state.dart';
import 'dash_counter.dart';
import 'firebase_notifier.dart';                           // And this one

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  FirebaseNotifier firebaseNotifier;                       // Add this line
  StoreState storeState = StoreState.loading;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [];

  bool get beautifiedDash => _beautifiedDashUpgrade;
  bool _beautifiedDashUpgrade = false;

  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter, this.firebaseNotifier) {     // Update this line
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    loadPurchases();
  }

Добавьте firebaseNotifier при создании DashPurchases в main.dart:

библиотека/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create: (context) => DashPurchases(
            context.read<DashCounter>(),
            context.read<FirebaseNotifier>(),
          ),
          lazy: false,
        ),

Добавьте метод получения для пользователя в FirebaseNotifier, чтобы вы могли передать идентификатор пользователя в функцию проверки покупки.

lib/logic/firebase_notifier.dart

  Future<FirebaseFirestore> get firestore async {
    var isInitialized = await _isInitialized.future;
    if (!isInitialized) {
      throw Exception('Firebase is not initialized');
    }
    return FirebaseFirestore.instance;
  }

  User? get user => FirebaseAuth.instance.currentUser;     // Add this line

  Future<void> load() async {
    // ...

Добавьте функцию _verifyPurchase в класс DashPurchases . Эта async функция возвращает логическое значение, указывающее, подтверждена ли покупка.

lib/logic/dash_purchases.dart

  Future<bool> _verifyPurchase(PurchaseDetails purchaseDetails) async {
    final url = Uri.parse('http://$serverIp:8080/verifypurchase');
    const headers = {
      'Content-type': 'application/json',
      'Accept': 'application/json',
    };
    final response = await http.post(
      url,
      body: jsonEncode({
        'source': purchaseDetails.verificationData.source,
        'productId': purchaseDetails.productID,
        'verificationData':
            purchaseDetails.verificationData.serverVerificationData,
        'userId': firebaseNotifier.user?.uid,
      }),
      headers: headers,
    );
    if (response.statusCode == 200) {
      return true;
    } else {
      return false;
    }
  }

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

lib/logic/dash_purchases.dart

  Future<void> _onPurchaseUpdate(
    List<PurchaseDetails> purchaseDetailsList,
  ) async {
    for (var purchaseDetails in purchaseDetailsList) {
      await _handlePurchase(purchaseDetails);
    }
    notifyListeners();
  }

  Future<void> _handlePurchase(PurchaseDetails purchaseDetails) async {
    if (purchaseDetails.status == PurchaseStatus.purchased) {
      // Send to server
      var validPurchase = await _verifyPurchase(purchaseDetails);

      if (validPurchase) {
        // Apply changes locally
        switch (purchaseDetails.productID) {
          case storeKeySubscription:
            counter.applyPaidMultiplier();
          case storeKeyConsumable:
            counter.addBoughtDashes(2000);
          case storeKeyUpgrade:
            _beautifiedDashUpgrade = true;
        }
      }
    }

    if (purchaseDetails.pendingCompletePurchase) {
      await iapConnection.completePurchase(purchaseDetails);
    }
  }

Теперь в приложении все готово для подтверждения покупок.

Настройка серверной службы

Далее настройте бэкенд для проверки покупок на бэкенде.

Создание обработчиков покупок

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

be50c207c5a2a519.png

Начните с добавления файла purchase_handler.dart в папку lib/ , где вы определяете абстрактный класс PurchaseHandler с двумя абстрактными методами для проверки двух разных типов покупок: подписки и отсутствия подписки.

lib/purchase_handler.dart

import 'products.dart';

/// Generic purchase handler,
/// must be implemented for Google Play and Apple Store
abstract class PurchaseHandler {
  /// Verify if non-subscription purchase (aka consumable) is valid
  /// and update the database
  Future<bool> handleNonSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  });

  /// Verify if subscription purchase (aka non-consumable) is valid
  /// and update the database
  Future<bool> handleSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  });
}

Как видите, каждый метод требует трех параметров:

  • userId: идентификатор вошедшего в систему пользователя, чтобы вы могли привязать покупки к этому пользователю.
  • productData: данные о продукте. Вы определите это через минуту.
  • token: токен, предоставленный пользователю магазином.

Кроме того, чтобы упростить использование этих обработчиков покупок, добавьте метод verifyPurchase() , который можно использовать как для подписок, так и для неподписок:

lib/purchase_handler.dart

  /// Verify if purchase is valid and update the database
  Future<bool> verifyPurchase({
    required String userId,
    required ProductData productData,
    required String token,
  }) async {
    switch (productData.type) {
      case ProductType.subscription:
        return handleSubscription(
          userId: userId,
          productData: productData,
          token: token,
        );
      case ProductType.nonSubscription:
        return handleNonSubscription(
          userId: userId,
          productData: productData,
          token: token,
        );
    }
  }

Теперь вы можете просто verifyPurchase в обоих случаях, но при этом иметь отдельные реализации!

Класс ProductData содержит базовую информацию о различных приобретаемых продуктах, включая идентификатор продукта (иногда также называемый SKU) и ProductType .

lib/products.dart

class ProductData {
  final String productId;
  final ProductType type;

  const ProductData(this.productId, this.type);
}

ProductType может быть подпиской или отсутствием подписки.

lib/products.dart

enum ProductType { subscription, nonSubscription }

Наконец, список продуктов определяется как карта в том же файле.

lib/products.dart

const productDataMap = {
  'dash_consumable_2k': ProductData(
    'dash_consumable_2k',
    ProductType.nonSubscription,
  ),
  'dash_upgrade_3d': ProductData(
    'dash_upgrade_3d',
    ProductType.nonSubscription,
  ),
  'dash_subscription_doubler': ProductData(
    'dash_subscription_doubler',
    ProductType.subscription,
  ),
};

Затем определите некоторые реализации заполнителей для Google Play Store и Apple App Store. Начните с Google Play:

Создайте lib/google_play_purchase_handler.dart и добавьте класс, расширяющий только что написанный PurchaseHandler :

lib/google_play_purchase_handler.dart

import 'dart:async';

import 'package:googleapis/androidpublisher/v3.dart' as ap;

import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';

class GooglePlayPurchaseHandler extends PurchaseHandler {
  final ap.AndroidPublisherApi androidPublisher;
  final IapRepository iapRepository;

  GooglePlayPurchaseHandler(this.androidPublisher, this.iapRepository);

  @override
  Future<bool> handleNonSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    return true;
  }

  @override
  Future<bool> handleSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    return true;
  }
}

На данный момент он возвращает true для методов-обработчиков; вы доберетесь до них позже.

Как вы могли заметить, конструктор принимает экземпляр IapRepository . Обработчик покупок использует этот экземпляр для последующего хранения информации о покупках в Firestore. Для связи с Google Play вы используете предоставленный AndroidPublisherApi .

Затем сделайте то же самое для обработчика магазина приложений. Создайте lib/app_store_purchase_handler.dart и добавьте класс, который снова расширяет PurchaseHandler :

lib/app_store_purchase_handler.dart

import 'dart:async';

import 'package:app_store_server_sdk/app_store_server_sdk.dart';

import 'constants.dart';
import 'iap_repository.dart';
import 'products.dart';
import 'purchase_handler.dart';

class AppStorePurchaseHandler extends PurchaseHandler {
  final IapRepository iapRepository;

  AppStorePurchaseHandler(this.iapRepository);

  @override
  Future<bool> handleNonSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) async {
    return true;
  }

  @override
  Future<bool> handleSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) async {
    return true;
  }
}

Отлично! Теперь у вас есть два обработчика покупок. Затем создайте конечную точку API проверки покупок.

Используйте обработчики покупок

Откройте bin/server.dart и создайте конечную точку API, используя shelf_route :

бен/server.dart

import 'dart:convert';

import 'package:firebase_backend_dart/helpers.dart';
import 'package:firebase_backend_dart/products.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

Future<void> main() async {
  final router = Router();

  final purchaseHandlers = await _createPurchaseHandlers();

  router.post('/verifypurchase', (Request request) async {
    final dynamic payload = json.decode(await request.readAsString());

    final (:userId, :source, :productData, :token) = getPurchaseData(payload);

    final result = await purchaseHandlers[source]!.verifyPurchase(
      userId: userId,
      productData: productData,
      token: token,
    );

    if (result) {
      return Response.ok('all good!');
    } else {
      return Response.internalServerError();
    }
  });

  await serveHandler(router.call);
}

({String userId, String source, ProductData productData, String token})
getPurchaseData(dynamic payload) {
  if (payload case {
    'userId': String userId,
    'source': String source,
    'productId': String productId,
    'verificationData': String token,
  }) {
    return (
      userId: userId,
      source: source,
      productData: productDataMap[productId]!,
      token: token,
    );
  } else {
    throw const FormatException('Unexpected JSON');
  }
}

Код делает следующее:

  1. Определите конечную точку POST, которая будет вызываться из приложения, которое вы создали ранее.
  2. Раскодируйте полезную нагрузку JSON и извлеките следующую информацию:
    1. userId : идентификатор вошедшего в систему пользователя.
    2. source : используемый магазин: app_store или google_play .
    3. productData : получено из созданной ранее productDataMap .
    4. token : содержит данные проверки для отправки в магазины.
  3. Вызов метода verifyPurchase либо для GooglePlayPurchaseHandler , либо для AppStorePurchaseHandler , в зависимости от источника.
  4. Если проверка прошла успешно, метод возвращает клиенту Response.ok .
  5. Если проверка не удалась, метод возвращает клиенту Response.internalServerError .

После создания конечной точки API вам необходимо настроить два обработчика покупок. Для этого вам необходимо загрузить ключи учетной записи службы, полученные на предыдущем шаге, и настроить доступ к различным службам, включая API Android Publisher и API Firebase Firestore. Затем создайте два обработчика покупок с разными зависимостями:

бен/server.dart

import 'dart:convert';
import 'dart:io'; // new

import 'package:firebase_backend_dart/app_store_purchase_handler.dart'; // new
import 'package:firebase_backend_dart/google_play_purchase_handler.dart'; // new
import 'package:firebase_backend_dart/helpers.dart';
import 'package:firebase_backend_dart/iap_repository.dart'; // new
import 'package:firebase_backend_dart/products.dart';
import 'package:firebase_backend_dart/purchase_handler.dart'; // new
import 'package:googleapis/androidpublisher/v3.dart' as ap; // new
import 'package:googleapis/firestore/v1.dart' as fs; // new
import 'package:googleapis_auth/auth_io.dart' as auth; // new
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';

Future<Map<String, PurchaseHandler>> _createPurchaseHandlers() async {
  // Configure Android Publisher API access
  final serviceAccountGooglePlay =
      File('assets/service-account-google-play.json').readAsStringSync();
  final clientCredentialsGooglePlay =
      auth.ServiceAccountCredentials.fromJson(serviceAccountGooglePlay);
  final clientGooglePlay =
      await auth.clientViaServiceAccount(clientCredentialsGooglePlay, [
    ap.AndroidPublisherApi.androidpublisherScope,
  ]);
  final androidPublisher = ap.AndroidPublisherApi(clientGooglePlay);

  // Configure Firestore API access
  final serviceAccountFirebase =
      File('assets/service-account-firebase.json').readAsStringSync();
  final clientCredentialsFirebase =
      auth.ServiceAccountCredentials.fromJson(serviceAccountFirebase);
  final clientFirebase =
      await auth.clientViaServiceAccount(clientCredentialsFirebase, [
    fs.FirestoreApi.cloudPlatformScope,
  ]);
  final firestoreApi = fs.FirestoreApi(clientFirebase);
  final dynamic json = jsonDecode(serviceAccountFirebase);
  final projectId = json['project_id'] as String;
  final iapRepository = IapRepository(firestoreApi, projectId);

  return {
    'google_play': GooglePlayPurchaseHandler(
      androidPublisher,
      iapRepository,
    ),
    'app_store': AppStorePurchaseHandler(
      iapRepository,
    ),
  };
}

Проверка покупок Android: реализация манипулятора покупки

Далее продолжайте реализацию обработчика покупок Google Play.

Google уже предоставляет пакеты Dart для взаимодействия с API, необходимыми для проверки покупок. Вы инициализировали их в файле server.dart и теперь используете их в классе GooglePlayPurchaseHandler .

Реализуйте обработчик для покупок без подписки:

lib/google_play_purchase_handler.dart

  /// Handle non-subscription purchases (one time purchases).
  ///
  /// Retrieves the purchase status from Google Play and updates
  /// the Firestore Database accordingly.
  @override
  Future<bool> handleNonSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    print(
      'GooglePlayPurchaseHandler.handleNonSubscription'
      '($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
    );

    try {
      // Verify purchase with Google
      final response = await androidPublisher.purchases.products.get(
        androidPackageId,
        productData.productId,
        token,
      );

      print('Purchases response: ${response.toJson()}');

      // Make sure an order ID exists
      if (response.orderId == null) {
        print('Could not handle purchase without order id');
        return false;
      }
      final orderId = response.orderId!;

      final purchaseData = NonSubscriptionPurchase(
        purchaseDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.purchaseTimeMillis ?? '0'),
        ),
        orderId: orderId,
        productId: productData.productId,
        status: _nonSubscriptionStatusFrom(response.purchaseState),
        userId: userId,
        iapSource: IAPSource.googleplay,
      );

      // Update the database
      if (userId != null) {
        // If we know the userId,
        // update the existing purchase or create it if it does not exist.
        await iapRepository.createOrUpdatePurchase(purchaseData);
      } else {
        // If we don't know the user ID, a previous entry must already
        // exist, and thus we'll only update it.
        await iapRepository.updatePurchase(purchaseData);
      }
      return true;
    } on ap.DetailedApiRequestError catch (e) {
      print(
        'Error on handle NonSubscription: $e\n'
        'JSON: ${e.jsonResponse}',
      );
    } catch (e) {
      print('Error on handle NonSubscription: $e\n');
    }
    return false;
  }

Обновить обработчик покупки подписки можно аналогичным образом:

lib/google_play_purchase_handler.dart

  /// Handle subscription purchases.
  ///
  /// Retrieves the purchase status from Google Play and updates
  /// the Firestore Database accordingly.
  @override
  Future<bool> handleSubscription({
    required String? userId,
    required ProductData productData,
    required String token,
  }) async {
    print(
      'GooglePlayPurchaseHandler.handleSubscription'
      '($userId, ${productData.productId}, ${token.substring(0, 5)}...)',
    );

    try {
      // Verify purchase with Google
      final response = await androidPublisher.purchases.subscriptions.get(
        androidPackageId,
        productData.productId,
        token,
      );

      print('Subscription response: ${response.toJson()}');

      // Make sure an order ID exists
      if (response.orderId == null) {
        print('Could not handle purchase without order id');
        return false;
      }
      final orderId = extractOrderId(response.orderId!);

      final purchaseData = SubscriptionPurchase(
        purchaseDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.startTimeMillis ?? '0'),
        ),
        orderId: orderId,
        productId: productData.productId,
        status: _subscriptionStatusFrom(response.paymentState),
        userId: userId,
        iapSource: IAPSource.googleplay,
        expiryDate: DateTime.fromMillisecondsSinceEpoch(
          int.parse(response.expiryTimeMillis ?? '0'),
        ),
      );

      // Update the database
      if (userId != null) {
        // If we know the userId,
        // update the existing purchase or create it if it does not exist.
        await iapRepository.createOrUpdatePurchase(purchaseData);
      } else {
        // If we don't know the user ID, a previous entry must already
        // exist, and thus we'll only update it.
        await iapRepository.updatePurchase(purchaseData);
      }
      return true;
    } on ap.DetailedApiRequestError catch (e) {
      print(
        'Error on handle Subscription: $e\n'
        'JSON: ${e.jsonResponse}',
      );
    } catch (e) {
      print('Error on handle Subscription: $e\n');
    }
    return false;
  }
}

Добавьте следующий метод, чтобы упростить анализ идентификаторов заказов, а также два метода для анализа статуса покупки.

lib/google_play_purchase_handler.dart

NonSubscriptionStatus _nonSubscriptionStatusFrom(int? state) {
  return switch (state) {
    0 => NonSubscriptionStatus.completed,
    2 => NonSubscriptionStatus.pending,
    _ => NonSubscriptionStatus.cancelled,
  };
}

SubscriptionStatus _subscriptionStatusFrom(int? state) {
  return switch (state) {
    // Payment pending
    0 => SubscriptionStatus.pending,
    // Payment received
    1 => SubscriptionStatus.active,
    // Free trial
    2 => SubscriptionStatus.active,
    // Pending deferred upgrade/downgrade
    3 => SubscriptionStatus.pending,
    // Expired or cancelled
    _ => SubscriptionStatus.expired,
  };
}

/// If a subscription suffix is present (..#) extract the orderId.
String extractOrderId(String orderId) {
  final orderIdSplit = orderId.split('..');
  if (orderIdSplit.isNotEmpty) {
    orderId = orderIdSplit[0];
  }
  return orderId;
}

Ваши покупки в Google Play теперь должны быть проверены и сохранены в базе данных.

Далее переходим к покупкам в App Store для iOS.

Проверка покупок iOS: реализация обработчика покупок

Для проверки покупок в App Store существует сторонний пакет Dart с именем app_store_server_sdk , который упрощает этот процесс.

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

lib/app_store_purchase_handler.dart

  final _iTunesAPI = ITunesApi(
    ITunesHttpClient(ITunesEnvironment.sandbox(), loggingEnabled: true),
  );

Теперь, в отличие от API Google Play, App Store использует одни и те же конечные точки API как для подписок, так и для неподписок. Это означает, что вы можете использовать одну и ту же логику для обоих обработчиков. Объедините их вместе, чтобы они вызывали одну и ту же реализацию:

lib/app_store_purchase_handler.dart

  @override
  Future<bool> handleNonSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) {
    return handleValidation(userId: userId, token: token);
  }

  @override
  Future<bool> handleSubscription({
    required String userId,
    required ProductData productData,
    required String token,
  }) {
    return handleValidation(userId: userId, token: token);
  }

  /// Handle purchase validation.
  Future<bool> handleValidation({
    required String userId,
    required String token,
  }) async {

    // See next step
  }

Теперь реализуем handleValidation :

lib/app_store_purchase_handler.dart

  /// Handle purchase validation.
  Future<bool> handleValidation({
    required String userId,
    required String token,
  }) async {
    print('AppStorePurchaseHandler.handleValidation');
    final response = await _iTunesAPI.verifyReceipt(
      password: appStoreSharedSecret,
      receiptData: token,
    );
    print('response: $response');
    if (response.status == 0) {
      print('Successfully verified purchase');
      final receipts = response.latestReceiptInfo ?? [];
      for (final receipt in receipts) {
        final product = productDataMap[receipt.productId];
        if (product == null) {
          print('Error: Unknown product: ${receipt.productId}');
          continue;
        }
        switch (product.type) {
          case ProductType.nonSubscription:
            await iapRepository.createOrUpdatePurchase(
              NonSubscriptionPurchase(
                userId: userId,
                productId: receipt.productId ?? '',
                iapSource: IAPSource.appstore,
                orderId: receipt.originalTransactionId ?? '',
                purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.originalPurchaseDateMs ?? '0'),
                ),
                type: product.type,
                status: NonSubscriptionStatus.completed,
              ),
            );
            break;
          case ProductType.subscription:
            await iapRepository.createOrUpdatePurchase(
              SubscriptionPurchase(
                userId: userId,
                productId: receipt.productId ?? '',
                iapSource: IAPSource.appstore,
                orderId: receipt.originalTransactionId ?? '',
                purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.originalPurchaseDateMs ?? '0'),
                ),
                type: product.type,
                expiryDate: DateTime.fromMillisecondsSinceEpoch(
                  int.parse(receipt.expiresDateMs ?? '0'),
                ),
                status: SubscriptionStatus.active,
              ),
            );
            break;
        }
      }
      return true;
    } else {
      print('Error: Status: ${response.status}');
      return false;
    }
  }

Ваши покупки в App Store теперь должны быть проверены и сохранены в базе данных!

Запустите серверную часть

На этом этапе вы можете запустить dart bin/server.dart для обслуживания конечной точки /verifypurchase .

$ dart bin/server.dart
Serving at http://0.0.0.0:8080

11. Следите за покупками

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

Сначала настройте обработку событий магазина на бэкэнде с помощью созданного вами бэкэнда Dart.

Обработка событий хранилища на серверной стороне

Магазины имеют возможность информировать ваш сервер о любых происходящих событиях выставления счетов, например о продлении подписки. Вы можете обрабатывать эти события в своем бэкэнде, чтобы поддерживать актуальность покупок в вашей базе данных. В этом разделе настройте это как для Google Play Store, так и для Apple App Store.

Обработка событий выставления счетов в Google Play

Google Play предоставляет события выставления счетов через так называемую облачную тему pub/sub . По сути, это очереди сообщений, в которых сообщения могут публиковаться, а также использоваться.

Поскольку эта функция специфична для Google Play, вы включаете ее в GooglePlayPurchaseHandler .

Начните с открытия lib/google_play_purchase_handler.dart и добавления импорта PubsubApi :

lib/google_play_purchase_handler.dart

import 'package:googleapis/pubsub/v1.dart' as pubsub;

Затем передайте PubsubApi в GooglePlayPurchaseHandler и измените конструктор класса, чтобы создать Timer следующим образом:

lib/google_play_purchase_handler.dart

class GooglePlayPurchaseHandler extends PurchaseHandler {
  final ap.AndroidPublisherApi androidPublisher;
  final IapRepository iapRepository;
  final pubsub.PubsubApi pubsubApi; // new

  GooglePlayPurchaseHandler(
    this.androidPublisher,
    this.iapRepository,
    this.pubsubApi, // new
  ) {
    // Poll messages from Pub/Sub every 10 seconds
    Timer.periodic(Duration(seconds: 10), (_) {
      _pullMessageFromPubSub();
    });
  }

Timer настроен на вызов метода _pullMessageFromPubSub каждые десять секунд. Вы можете настроить продолжительность по своему усмотрению.

Затем создайте _pullMessageFromPubSub

lib/google_play_purchase_handler.dart

  /// Process messages from Google Play
  /// Called every 10 seconds
  Future<void> _pullMessageFromPubSub() async {
    print('Polling Google Play messages');
    final request = pubsub.PullRequest(maxMessages: 1000);
    final topicName =
        'projects/$googleCloudProjectId/subscriptions/$googlePlayPubsubBillingTopic-sub';
    final pullResponse = await pubsubApi.projects.subscriptions.pull(
      request,
      topicName,
    );
    final messages = pullResponse.receivedMessages ?? [];
    for (final message in messages) {
      final data64 = message.message?.data;
      if (data64 != null) {
        await _processMessage(data64, message.ackId);
      }
    }
  }

  Future<void> _processMessage(String data64, String? ackId) async {
    final dataRaw = utf8.decode(base64Decode(data64));
    print('Received data: $dataRaw');
    final dynamic data = jsonDecode(dataRaw);
    if (data['testNotification'] != null) {
      print('Skip test messages');
      if (ackId != null) {
        await _ackMessage(ackId);
      }
      return;
    }
    final dynamic subscriptionNotification = data['subscriptionNotification'];
    final dynamic oneTimeProductNotification =
        data['oneTimeProductNotification'];
    if (subscriptionNotification != null) {
      print('Processing Subscription');
      final subscriptionId =
          subscriptionNotification['subscriptionId'] as String;
      final purchaseToken = subscriptionNotification['purchaseToken'] as String;
      final productData = productDataMap[subscriptionId]!;
      final result = await handleSubscription(
        userId: null,
        productData: productData,
        token: purchaseToken,
      );
      if (result && ackId != null) {
        await _ackMessage(ackId);
      }
    } else if (oneTimeProductNotification != null) {
      print('Processing NonSubscription');
      final sku = oneTimeProductNotification['sku'] as String;
      final purchaseToken =
          oneTimeProductNotification['purchaseToken'] as String;
      final productData = productDataMap[sku]!;
      final result = await handleNonSubscription(
        userId: null,
        productData: productData,
        token: purchaseToken,
      );
      if (result && ackId != null) {
        await _ackMessage(ackId);
      }
    } else {
      print('invalid data');
    }
  }

  /// ACK Messages from Pub/Sub
  Future<void> _ackMessage(String id) async {
    print('ACK Message');
    final request = pubsub.AcknowledgeRequest(ackIds: [id]);
    final subscriptionName =
        'projects/$googleCloudProjectId/subscriptions/$googlePlayPubsubBillingTopic-sub';
    await pubsubApi.projects.subscriptions.acknowledge(
      request,
      subscriptionName,
    );
  }

Только что добавленный код связывается с темой Pub/Sub из Google Cloud каждые десять секунд и запрашивает новые сообщения. Затем обрабатывает каждое сообщение в методе _processMessage .

Этот метод декодирует входящие сообщения и получает обновленную информацию о каждой покупке, как о подписке, так и об отсутствии подписки, вызывая при необходимости существующий handleSubscription или handleNonSubscription .

Каждое сообщение необходимо подтвердить с помощью метода _askMessage .

Затем добавьте необходимые зависимости в файл server.dart . Добавьте PubsubApi.cloudPlatformScope в конфигурацию учетных данных:

бен/server.dart

import 'package:googleapis/pubsub/v1.dart' as pubsub;      // Add this import

  final clientGooglePlay = await auth
      .clientViaServiceAccount(clientCredentialsGooglePlay, [
        ap.AndroidPublisherApi.androidpublisherScope,
        pubsub.PubsubApi.cloudPlatformScope,               // Add this line
      ]);

Затем создайте экземпляр PubsubApi:

бен/server.dart

  final pubsubApi = pubsub.PubsubApi(clientGooglePlay);

И, наконец, передайте его конструктору GooglePlayPurchaseHandler :

бен/server.dart

  return {
    'google_play': GooglePlayPurchaseHandler(
      androidPublisher,
      iapRepository,
      pubsubApi,                                           // Add this line
    ),
    'app_store': AppStorePurchaseHandler(
      iapRepository,
    ),
  };

Настройка Google Play

Вы написали код для использования событий выставления счетов из темы публикации/подписки, но вы не создали тему публикации/подписки и не публикуете какие-либо события выставления счетов. Пришло время это настроить.

Сначала создайте тему pub/sub:

  1. Установите значение googleCloudProjectId в constants.dart как идентификатор вашего проекта Google Cloud.
  2. Посетите страницу Cloud Pub/Sub в Google Cloud Console.
  3. Убедитесь, что вы находитесь в своем проекте Firebase, и нажмите + Создать тему . d5ebf6897a0a8bf5.png
  4. Дайте новой теме имя, идентичное значению, установленному для googlePlayPubsubBillingTopic в constants.dart . В данном случае назовите его play_billing . Если вы выберете что-то другое, обязательно обновите constants.dart . Создайте тему. 20d690fc543c4212.png
  5. В списке тем публикации/подписки нажмите на три вертикальные точки только что созданной темы и нажмите «Просмотр разрешений» . ea03308190609fb.png
  6. На боковой панели справа выберите «Добавить принципала» .
  7. Здесь добавьте google-play-developer-notifications@system.gserviceaccount.com и назначьте ему роль Pub/Sub Publisher . 55631ec0549215bc.png
  8. Сохраните изменения разрешений.
  9. Скопируйте название темы, которую вы только что создали.
  10. Снова откройте Play Console и выберите свое приложение из списка «Все приложения» .
  11. Прокрутите вниз и выберите «Монетизация» > «Настройка монетизации» .
  12. Заполните тему полностью и сохраните изменения. 7e5e875dc6ce5d54.png

Все платежные события Google Play теперь будут публиковаться в этой теме.

Обработка событий выставления счетов в App Store

Затем сделайте то же самое для событий выставления счетов в App Store. Существует два эффективных способа реализации обработки обновлений при покупках в App Store. Один из них — реализация веб-перехватчика, который вы предоставляете Apple, и который они используют для связи с вашим сервером. Второй способ, который вы найдете в этой кодовой лаборатории, — это подключение к API сервера App Store и получение информации о подписке вручную.

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

В производственной среде в идеале хотелось бы иметь и то, и другое. Вебхук для получения событий из App Store и API сервера на случай, если вы пропустили событие или вам нужно дважды проверить статус подписки.

Начните с открытия lib/app_store_purchase_handler.dart и добавления зависимости AppStoreServerAPI :

lib/app_store_purchase_handler.dart

  final AppStoreServerAPI appStoreServerAPI;                 // Add this member

  AppStorePurchaseHandler(
    this.iapRepository,
    this.appStoreServerAPI,                                  // And this parameter
  );

Измените конструктор, добавив таймер, который будет вызывать метод _pullStatus . Этот таймер будет вызывать метод _pullStatus каждые 10 секунд. Вы можете настроить продолжительность таймера в соответствии со своими потребностями.

lib/app_store_purchase_handler.dart

  AppStorePurchaseHandler(this.iapRepository, this.appStoreServerAPI) {
    // Poll Subscription status every 10 seconds.
    Timer.periodic(Duration(seconds: 10), (_) {
      _pullStatus();
    });
  }

Затем создайте метод _pullStatus следующим образом:

lib/app_store_purchase_handler.dart

  /// Request the App Store for the latest subscription status.
  /// Updates all App Store subscriptions in the database.
  /// NOTE: This code only handles when a subscription expires as example.
  Future<void> _pullStatus() async {
    print('Polling App Store');
    final purchases = await iapRepository.getPurchases();
    // filter for App Store subscriptions
    final appStoreSubscriptions = purchases.where(
      (element) =>
          element.type == ProductType.subscription &&
          element.iapSource == IAPSource.appstore,
    );
    for (final purchase in appStoreSubscriptions) {
      final status = await appStoreServerAPI.getAllSubscriptionStatuses(
        purchase.orderId,
      );
      // Obtain all subscriptions for the order ID.
      for (final subscription in status.data) {
        // Last transaction contains the subscription status.
        for (final transaction in subscription.lastTransactions) {
          final expirationDate = DateTime.fromMillisecondsSinceEpoch(
            transaction.transactionInfo.expiresDate ?? 0,
          );
          // Check if subscription has expired.
          final isExpired = expirationDate.isBefore(DateTime.now());
          print('Expiration Date: $expirationDate - isExpired: $isExpired');
          // Update the subscription status with the new expiration date and status.
          await iapRepository.updatePurchase(
            SubscriptionPurchase(
              userId: null,
              productId: transaction.transactionInfo.productId,
              iapSource: IAPSource.appstore,
              orderId: transaction.originalTransactionId,
              purchaseDate: DateTime.fromMillisecondsSinceEpoch(
                transaction.transactionInfo.originalPurchaseDate,
              ),
              type: ProductType.subscription,
              expiryDate: expirationDate,
              status: isExpired
                  ? SubscriptionStatus.expired
                  : SubscriptionStatus.active,
            ),
          );
        }
      }
    }
  }

Этот метод работает следующим образом:

  1. Получает список активных подписок из Firestore с помощью IapRepository.
  2. Для каждого заказа он запрашивает статус подписки у API сервера App Store.
  3. Получает последнюю транзакцию для этой покупки подписки.
  4. Проверяет срок годности.
  5. Обновляет статус подписки в Firestore. Если срок ее действия истек, он будет помечен как таковой.

Наконец, добавьте весь необходимый код для настройки доступа к API сервера App Store:

бен/server.dart

import 'package:app_store_server_sdk/app_store_server_sdk.dart';  // Add this import
import 'package:firebase_backend_dart/constants.dart';            // And this one.


  // add from here
  final subscriptionKeyAppStore = File(
    'assets/SubscriptionKey.p8',
  ).readAsStringSync();

  // Configure Apple Store API access
  var appStoreEnvironment = AppStoreEnvironment.sandbox(
    bundleId: bundleId,
    issuerId: appStoreIssuerId,
    keyId: appStoreKeyId,
    privateKey: subscriptionKeyAppStore,
  );

  // Stored token for Apple Store API access, if available
  final file = File('assets/appstore.token');
  String? appStoreToken;
  if (file.existsSync() && file.lengthSync() > 0) {
    appStoreToken = file.readAsStringSync();
  }

  final appStoreServerAPI = AppStoreServerAPI(
    AppStoreServerHttpClient(
      appStoreEnvironment,
      jwt: appStoreToken,
      jwtTokenUpdatedCallback: (token) {
        file.writeAsStringSync(token);
      },
    ),
  );
  // to here

  return {
    'google_play': GooglePlayPurchaseHandler(
      androidPublisher,
      iapRepository,
      pubsubApi,
    ),
    'app_store': AppStorePurchaseHandler(
      iapRepository,
      appStoreServerAPI,                                     // Add this argument
    ),
  };

Настройка магазина приложений

Далее настройте App Store:

  1. Войдите в App Store Connect и выберите «Пользователи и доступ» .
  2. Перейдите в «Интеграции» > «Ключи» > «Покупка в приложении» .
  3. Нажмите на значок «плюс», чтобы добавить новый.
  4. Дайте ему имя, например «Ключ Codelab».
  5. Загрузите файл p8, содержащий ключ.
  6. Скопируйте его в папку ресурсов с именем SubscriptionKey.p8 .
  7. Скопируйте идентификатор ключа из вновь созданного ключа и установите для него константу appStoreKeyId в файле lib/constants.dart .
  8. Скопируйте идентификатор эмитента прямо вверху списка ключей и установите для него константу appStoreIssuerId в файле lib/constants.dart .

9540ea9ada3da151.png

Отслеживайте покупки на устройстве

Самый безопасный способ отслеживать ваши покупки — на стороне сервера, поскольку клиент сложно защитить, но вам нужен какой-то способ вернуть информацию клиенту, чтобы приложение могло действовать на основе информации о статусе подписки. Сохраняя покупки в Firestore, вы можете синхронизировать данные с клиентом и автоматически обновлять их.

Вы уже включили IAPRepo в приложение, которое представляет собой репозиторий Firestore, содержащий все данные о покупках пользователя в List<PastPurchase> purchases . Репозиторий также содержит hasActiveSubscription, что справедливо при покупке с productId storeKeySubscription со статусом, срок действия которого не истек. Если пользователь не вошел в систему, список пуст.

lib/repo/iap_repo.dart

  void updatePurchases() {
    _purchaseSubscription?.cancel();
    var user = _user;
    if (user == null) {
      purchases = [];
      hasActiveSubscription = false;
      hasUpgrade = false;
      return;
    }
    var purchaseStream = _firestore
        .collection('purchases')
        .where('userId', isEqualTo: user.uid)
        .snapshots();
    _purchaseSubscription = purchaseStream.listen((snapshot) {
      purchases = snapshot.docs.map((document) {
        var data = document.data();
        return PastPurchase.fromJson(data);
      }).toList();

      hasActiveSubscription = purchases.any(
        (element) =>
            element.productId == storeKeySubscription &&
            element.status != Status.expired,
      );

      hasUpgrade = purchases.any(
        (element) => element.productId == storeKeyUpgrade,
      );

      notifyListeners();
    });
  }

Вся логика покупок находится в классе DashPurchases , и именно здесь следует применять или удалять подписки. Итак, добавьте iapRepo в качестве свойства в класс и назначьте iapRepo в конструкторе. Затем непосредственно добавьте прослушиватель в конструктор и удалите его в методе dispose() . Сначала слушатель может быть просто пустой функцией. Поскольку IAPRepo является ChangeNotifier , и вы вызываете notifyListeners() каждый раз, когда изменяются покупки в Firestore, метод purchasesUpdate() всегда вызывается при изменении приобретенных продуктов.

lib/logic/dash_purchases.dart

import '../repo/iap_repo.dart';                              // Add this import

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  FirebaseNotifier firebaseNotifier;
  StoreState storeState = StoreState.loading;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  List<PurchasableProduct> products = [];
  IAPRepo iapRepo;                                           // Add this line

  bool get beautifiedDash => _beautifiedDashUpgrade;
  bool _beautifiedDashUpgrade = false;
  final iapConnection = IAPConnection.instance;

  // Add this.iapRepo as a parameter
  DashPurchases(this.counter, this.firebaseNotifier, this.iapRepo) {
    final purchaseUpdated = iapConnection.purchaseStream;
    _subscription = purchaseUpdated.listen(
      _onPurchaseUpdate,
      onDone: _updateStreamOnDone,
      onError: _updateStreamOnError,
    );
    iapRepo.addListener(purchasesUpdate);
    loadPurchases();
  }

  Future<void> loadPurchases() async {
    // Elided.
  }

  @override
  void dispose() {
    _subscription.cancel();
    iapRepo.removeListener(purchasesUpdate);                 // Add this line
    super.dispose();
  }

  void purchasesUpdate() {
    //TODO manage updates
  }

Затем передайте IAPRepo конструктору в main.dart. Вы можете получить репозиторий, используя context.read , поскольку он уже создан в Provider .

библиотека/main.dart

        ChangeNotifierProvider<DashPurchases>(
          create: (context) => DashPurchases(
            context.read<DashCounter>(),
            context.read<FirebaseNotifier>(),
            context.read<IAPRepo>(),                         // Add this line
          ),
          lazy: false,
        ),

Затем напишите код функции purchaseUpdate() . В dash_counter.dart, методы applyPaidMultiplier и removePaidMultiplier устанавливают множитель равным 10 или 1 соответственно, поэтому вам не нужно проверять, применена ли уже подписка. При изменении статуса подписки вы также обновляете статус приобретаемого продукта, чтобы на странице покупки можно было показать, что он уже активен. Установите свойство _beautifiedDashUpgrade в зависимости от того, куплено ли обновление.

lib/logic/dash_purchases.dart

  void purchasesUpdate() {
    var subscriptions = <PurchasableProduct>[];
    var upgrades = <PurchasableProduct>[];
    // Get a list of purchasable products for the subscription and upgrade.
    // This should be 1 per type.
    if (products.isNotEmpty) {
      subscriptions = products
          .where((element) => element.productDetails.id == storeKeySubscription)
          .toList();
      upgrades = products
          .where((element) => element.productDetails.id == storeKeyUpgrade)
          .toList();
    }

    // Set the subscription in the counter logic and show/hide purchased on the
    // purchases page.
    if (iapRepo.hasActiveSubscription) {
      counter.applyPaidMultiplier();
      for (var element in subscriptions) {
        _updateStatus(element, ProductStatus.purchased);
      }
    } else {
      counter.removePaidMultiplier();
      for (var element in subscriptions) {
        _updateStatus(element, ProductStatus.purchasable);
      }
    }

    // Set the Dash beautifier and show/hide purchased on
    // the purchases page.
    if (iapRepo.hasUpgrade != _beautifiedDashUpgrade) {
      _beautifiedDashUpgrade = iapRepo.hasUpgrade;
      for (var element in upgrades) {
        _updateStatus(
          element,
          _beautifiedDashUpgrade
              ? ProductStatus.purchased
              : ProductStatus.purchasable,
        );
      }
      notifyListeners();
    }
  }

  void _updateStatus(PurchasableProduct product, ProductStatus status) {
    if (product.status != ProductStatus.purchased) {
      product.status = ProductStatus.purchased;
      notifyListeners();
    }
  }

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

12. Все готово!

Поздравляю!!! Вы завершили кодовую лабораторию. Вы можете найти готовый код для этой лаборатории кода в разделе android_studio_folder.png полная папка.

Чтобы узнать больше, попробуйте другие лаборатории разработки кода Flutter .