Flutter 앱에 인앱 구매 추가

1. 소개

최종 업데이트: 2023년 7월 11일

Flutter 앱에 인앱 구매를 추가하려면 앱 스토어와 Play 스토어를 올바르게 설정하고 구매를 인증하며 정기 결제 혜택과 같은 필수 권한을 부여해야 합니다.

이 Codelab에서는 세 가지 유형의 인앱 구매를 앱에 추가하고 (개발자에게 제공) Firebase가 포함된 Dart 백엔드를 사용하여 이러한 구매를 확인합니다. 제공된 앱인 Dash Clicker에는 Dash 마스코트를 통화로 사용하는 게임이 포함되어 있습니다. 다음 구매 옵션을 추가합니다.

  1. 한 번에 2,000개의 Dash를 구매할 수 있는 반복 가능한 구매 옵션입니다.
  2. 이전 스타일의 Dash를 현대적인 스타일의 Dash로 만들기 위한 일회성 업그레이드 구매입니다.
  3. 자동으로 생성된 클릭수를 두 배로 늘린 구독

첫 번째 구매 옵션은 사용자에게 2000 Dash라는 직접적인 혜택을 제공합니다. 사용자가 직접 사용할 수 있으며 여러 번 구매할 수 있습니다. 이는 직접 소비되며 여러 번 소비될 수 있으므로 소비성이라고 합니다.

두 번째 옵션은 Dash를 더 아름다운 Dash로 업그레이드합니다. 한 번만 구매하면 영구히 사용할 수 있습니다. 이러한 구매는 앱에서 사용할 수 없지만 영원히 유효하므로 비소비성 구매라고 합니다.

세 번째이자 마지막 구매 옵션은 구독입니다. 정기 결제가 활성 상태인 동안 사용자는 Dashes를 더 빨리 사용할 수 있지만, 사용자가 정기 결제 지불을 중지하면 혜택도 사라집니다.

백엔드 서비스 (또한 사용자에게 제공)는 Dart 앱으로 실행되고 구매가 이루어졌는지 확인하고 Firestore를 사용하여 저장합니다. 프로세스를 더 쉽게 하는 데 Firestore가 사용되지만 프로덕션 앱에서는 모든 유형의 백엔드 서비스를 사용할 수 있습니다.

300123416ebc8dc1.png 7145d0fffe6ea741.png 646317a79be08214.png

빌드할 항목

  • 소비성 구매 및 정기 결제를 지원하도록 앱을 확장합니다.
  • 또한 Dart 백엔드 앱을 확장하여 구매한 아이템을 확인하고 저장합니다.

학습 내용

  • 구매 가능한 제품으로 App Store 및 Play 스토어를 구성하는 방법
  • 매장과 통신하여 구매를 확인하고 Firestore에 저장하는 방법
  • 앱에서 구매를 관리하는 방법

필요한 항목

  • Android 스튜디오 4.1 이상
  • Xcode 12 이상(iOS 개발용)
  • Flutter SDK

2. 개발 환경 설정

이 Codelab을 시작하려면 코드를 다운로드하고 iOS의 번들 식별자와 Android의 패키지 이름을 변경합니다.

코드 다운로드

명령줄에서 GitHub 저장소를 클론하려면 다음 명령어를 사용합니다.

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

또는 GitHub의 CLI 도구가 설치되어 있는 경우 다음 명령어를 사용합니다.

gh repo clone flutter/codelabs flutter-codelabs

샘플 코드는 Codelab 모음 코드가 포함된 flutter-codelabs 디렉터리에 클론됩니다. 이 Codelab의 코드는 flutter-codelabs/in_app_purchases에 있습니다.

flutter-codelabs/in_app_purchases 아래의 디렉터리 구조에는 이름이 지정된 각 단계의 마지막에 있어야 하는 위치에 대한 일련의 스냅샷이 포함되어 있습니다. 시작 코드는 0단계에 있으므로 다음과 같이 일치하는 파일을 쉽게 찾을 수 있습니다.

cd flutter-codelabs/in_app_purchases/step_00

앞으로 건너뛰거나 단계 후 표시되는 내용을 확인하려면 관심 있는 단계의 이름을 딴 디렉토리를 살펴보세요. 마지막 단계의 코드는 complete 폴더 아래에 있습니다.

시작 프로젝트 설정

원하는 IDE의 step_00에서 시작 프로젝트를 엽니다. 스크린샷에 Android 스튜디오를 사용했지만 Visual Studio Code도 좋은 옵션입니다. 어느 편집기를 사용하든 최신 Dart 및 Flutter 플러그인이 설치되어 있는지 확인합니다.

개발자는 앱 스토어 및 Play 스토어와 통신하여 어떤 제품이 얼마에 얼마에 제공되는지 파악해야 합니다. 모든 앱은 고유 ID로 식별됩니다. iOS App Store의 경우에는 번들 식별자라고 하고 Android Play 스토어의 경우에는 애플리케이션 ID입니다. 이러한 식별자는 일반적으로 역방향 도메인 이름 표기법을 사용하여 만들어집니다. 예를 들어 flutter.dev용 인앱 구매 앱을 만들 때는 dev.flutter.inapppurchase를 사용합니다. 앱의 식별자를 생각해 보면, 이제 프로젝트 설정에서 이를 설정합니다.

먼저 iOS용 번들 식별자를 설정합니다.

Android 스튜디오에서 프로젝트를 열고 iOS 폴더를 마우스 오른쪽 버튼으로 클릭하고 Flutter를 클릭한 후 Xcode 앱에서 모듈을 엽니다.

942772eb9a73bfaa.png

Xcode의 폴더 구조에서 Runner 프로젝트가 맨 위에 있고 Runner 프로젝트 아래에 Flutter, Runner, Products 대상이 있습니다. Runner(실행기)를 더블클릭하여 프로젝트 설정을 수정하고 Signing &(서명 및 기능. Team(팀) 필드에서 선택한 번들 식별자를 입력하여 팀을 설정합니다.

812f919d965c649a.jpeg

이제 Xcode를 닫고 Android 스튜디오로 돌아가서 Android 구성을 완료할 수 있습니다. 이렇게 하려면 android/app,에서 build.gradle 파일을 열고 applicationId (아래 스크린샷의 37행)을 iOS 번들 식별자와 동일한 애플리케이션 ID로 변경합니다. iOS 저장소와 Android 저장소의 ID가 동일할 필요는 없지만 동일하게 유지하면 오류가 발생할 가능성이 낮으므로 이 Codelab에서는 동일한 식별자도 사용합니다.

5c4733ac560ae8c2.png

3. 플러그인 설치

Codelab의 이 부분에서는 in_app_purchase 플러그인을 설치합니다.

pubspec에 종속 항목 추가

pubspec의 종속 항목에 in_app_purchase를 추가하여 pubspec에 in_app_purchase를 추가합니다.

$ cd app
$ flutter pub add in_app_purchase

pubspec.yaml

dependencies:
  ..
  cloud_firestore: ^4.0.3
  firebase_auth: ^4.2.2
  firebase_core: ^2.5.0
  google_sign_in: ^6.0.1
  http: ^0.13.4
  in_app_purchase: ^3.0.1
  intl: ^0.18.0
  provider: ^6.0.2
  ..

pub get을 클릭하여 패키지를 다운로드하거나 명령줄에서 flutter pub get을 실행합니다.

4. App Store 설정

iOS에서 인앱 구매를 설정하고 테스트하려면 App Store에서 새 앱을 만들어 구매 가능한 제품을 만들어야 합니다. 검토를 위해 앱을 게시하거나 Apple에 전송할 필요가 없습니다. 이렇게 하려면 개발자 계정이 필요합니다. 계정이 없는 경우 Apple 개발자 프로그램에 등록합니다.

인앱 구매를 사용하려면 App Store Connect에서 유료 앱에 대한 유효한 계약도 있어야 합니다. https://appstoreconnect.apple.com/으로 이동하여 약관, 세금 및 은행 거래를 클릭합니다.

6e373780e5e24a6f.png

무료 및 유료 앱에 관한 계약이 여기에 표시됩니다. 무료 앱은 활성 상태이고 유료 앱의 상태는 새 상태입니다. 약관을 읽고 동의한 후 모든 필수 정보를 입력해야 합니다.

74c73197472c9aec.png

모든 항목이 올바르게 설정되면 유료 앱 상태가 활성화됩니다. 활성 계약이 없으면 인앱 구매를 시도해 볼 수 없기 때문에 이 단계는 매우 중요합니다.

4a100bbb8cafdbbf.jpeg

앱 ID 등록

Apple 개발자 포털에서 새 식별자를 만듭니다.

55d7e592d9a3fc7b.png

앱 ID 선택

13f125598b72ca77.png

앱 선택

41ac4c13404e2526.png

설명을 입력하고 번들 ID가 이전에 XCode에서 설정한 값과 일치하도록 번들 ID를 설정합니다.

9d2c940ad80deeef.png

새로운 앱 ID를 만드는 방법을 자세히 알아보려면 개발자 계정 도움말을 참고하세요 .

새 앱 만들기

App Store Connect에서 고유한 번들 식별자로 새 앱을 만듭니다.

10509b17fbf031bd.png

5b7c0bb684ef52c7.png

새 앱을 만들고 계약을 관리하는 방법을 자세히 알아보려면 App Store Connect 도움말을 참고하세요.

인앱 구매를 테스트하려면 샌드박스 테스트 사용자가 필요합니다. 테스트 사용자는 iTunes에 연결하지 말고 인앱 구매 테스트용으로만 사용해야 합니다. Apple 계정에서 이미 사용 중인 이메일 주소는 사용할 수 없습니다. 사용자 및 액세스에서 샌드박스 아래의 테스터로 이동하여 새 샌드박스 계정을 만들거나 기존 샌드박스 Apple ID를 관리합니다.

3ca2b26d4e391a4c.jpeg

이제 iPhone에서 Settings(설정) > 앱 스토어 > 샌드박스 계정

c7dadad2c1d448fa.jpeg 5363f87efcddaa4.jpeg

인앱 구매 구성

이제 구매 가능한 세 가지 항목을 구성합니다.

  • dash_consumable_2k: 여러 번 구매할 수 있는 소비성 구매로, 사용자에게 구매당 2,000개의 Dash (인앱 통화)가 제공됩니다.
  • dash_upgrade_3d: 비소비성 '업그레이드' 사용자가 클릭할 수 있는 외관상 다른 Dash를 제공합니다.
  • dash_subscription_doubler: 구독 기간 동안 사용자에게 클릭당 2배 많은 대시를 부여하는 구독입니다.

d156b2f5bac43ca8.png

인앱 구매 > 관리를 클릭합니다.

지정된 ID로 인앱 구매를 만듭니다.

  1. dash_consumable_2k소모성으로 설정합니다.

dash_consumable_2k를 제품 ID로 사용합니다. 참조 이름은 App Store Connect에서만 사용됩니다. dash consumable 2k로 설정하고 구매에 사용할 현지화를 추가하기만 하면 됩니다. 2000 dashes fly out를 설명으로 사용하여 구매 Spring is in the air를 호출합니다.

ec1701834fd8527.png

  1. dash_upgrade_3d비소비성으로 설정합니다.

dash_upgrade_3d를 제품 ID로 사용합니다. 참조 이름을 dash upgrade 3d로 설정하고 구매에 사용할 현지화를 추가합니다. Brings your dash back to the future를 설명으로 사용하여 구매 3D Dash를 호출합니다.

6765d4b711764c30.png

  1. dash_subscription_doubler을(를) 자동 갱신 정기 결제로 설정합니다.

정기 결제의 흐름은 약간 다릅니다. 먼저 참조 이름과 제품 ID를 설정해야 합니다.

6d29e08dae26a0c4.png

다음으로 구독 그룹을 만들어야 합니다. 여러 구독이 동일한 그룹에 속한 경우 사용자는 한 번에 이러한 구독 중 하나만 구독할 수 있으며 각 구독 간에 쉽게 업그레이드 또는 다운그레이드할 수 있습니다. 이 그룹의 이름은 subscriptions입니다.

5bd0da17a85ac076.png

다음으로 정기 결제 기간과 현지화를 입력합니다. 이 정기 결제의 이름을 Jet Engine로 지정하고 설명을 Doubles your clicks으로 지정합니다. 저장을 클릭합니다.

bd1b1d82eeee4cb3.png

저장 버튼을 클릭한 후 구독 가격을 추가합니다. 원하는 가격을 선택하세요.

d0bf39680ef0aa2e.png

이제 구매 목록에 3개의 구매가 표시됩니다.

99d5c4b446e8fecf.png

5. Play 스토어 설정

App Store와 마찬가지로 Play 스토어용 개발자 계정도 필요합니다. 아직 계정이 없으면 계정을 등록합니다.

새 앱 만들기

Google Play Console에서 새 앱을 만듭니다.

  1. Play Console을 엽니다.
  2. 모든 앱 > 앱 만들기
  3. 기본 언어를 선택하고 앱 제목을 추가합니다. Google Play에 표시하려는 앱 이름을 입력합니다. 이름은 나중에 변경할 수 있습니다.
  4. 애플리케이션이 게임임을 명시합니다. 나중에 변경할 수 있습니다.
  5. 애플리케이션이 무료인지 유료인지 지정합니다.
  6. Play 스토어 사용자가 이 애플리케이션에 관해 문의할 때 사용할 이메일 주소를 추가합니다.
  7. 콘텐츠 가이드라인 및 미국 수출법 선언을 작성합니다.
  8. 앱 만들기를 선택합니다.

앱을 만든 후 대시보드로 이동하여 앱 설정 섹션의 모든 작업을 완료합니다. 여기에서 콘텐츠 등급 및 스크린샷과 같은 앱에 관한 정보를 제공합니다. 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 파일을 수정하여 앱 서명을 구성합니다.

android 블록 앞에 속성 파일의 키 저장소 정보를 추가합니다.

   def keystoreProperties = new Properties()
   def keystorePropertiesFile = rootProject.file('key.properties')
   if (keystorePropertiesFile.exists()) {
       keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
   }

   android {
         // omitted
   }

key.properties 파일을 keystoreProperties 객체에 로드합니다.

buildTypes 블록 앞에 다음 코드를 추가합니다.

   buildTypes {
       release {
           // TODO: Add your own signing config for the release build.
           // Signing with the debug keys for now,
           // so `flutter run --release` works.
           signingConfig signingConfigs.debug
       }
   }

서명 구성 정보로 모듈의 build.gradle 파일에서 signingConfigs 블록을 구성합니다.

   signingConfigs {
       release {
           keyAlias keystoreProperties['keyAlias']
           keyPassword keystoreProperties['keyPassword']
           storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
           storePassword keystoreProperties['storePassword']
       }
   }
   buildTypes {
       release {
           signingConfig signingConfigs.release
       }
   }

이제 앱의 출시 빌드가 자동으로 서명됩니다.

앱 서명에 관한 자세한 내용은 developer.android.com에서 앱 서명을 참고하세요.

첫 번째 빌드 업로드

앱에 서명을 구성한 후에는 다음을 실행하여 애플리케이션을 빌드할 수 있어야 합니다.

flutter build appbundle

이 명령어는 기본적으로 출시 빌드를 생성하며 출력은 <your app dir>/build/app/outputs/bundle/release/에서 확인할 수 있습니다.

Google Play Console의 대시보드에서 출시 > 테스트 > 비공개 테스트로 이동하여 새로운 비공개 테스트 버전을 만듭니다.

이 Codelab에서는 계속해서 Google에 앱에 서명하므로 Play 앱 서명에서 계속을 눌러 선택합니다.

ba98446d9c5c40e0.png

다음으로, 빌드 명령어로 생성된 app-release.aab App Bundle을 업로드합니다.

저장을 클릭한 다음 버전 검토를 클릭합니다.

마지막으로 내부 테스트로 출시 시작을 클릭하여 내부 테스트 버전을 활성화합니다.

테스트 사용자 설정

인앱 구매를 테스트하려면 테스터의 Google 계정을 Google Play Console의 다음 두 위치에 추가해야 합니다.

  1. 특정 테스트 트랙으로 (내부 테스트)
  2. 라이선스 테스터로서

먼저 내부 테스트 트랙에 테스터를 추가합니다. 출시 > 테스트 > 내부 테스트를 클릭하고 테스터 탭을 클릭합니다.

a0d0394e85128f84.png

이메일 목록 만들기를 클릭하여 새 이메일 목록을 만듭니다. 목록 이름을 지정하고 인앱 구매 테스트에 액세스해야 하는 Google 계정의 이메일 주소를 추가합니다.

그런 다음 목록의 체크박스를 선택하고 변경사항 저장을 클릭합니다.

그런 다음 라이선스 테스터를 추가합니다.

  1. Google Play Console의 모든 앱 보기로 돌아갑니다.
  2. 설정 > 라이선스 테스트.
  3. 인앱 구매를 테스트할 수 있어야 하는 테스터와 동일한 이메일 주소를 추가합니다.
  4. 라이선스 응답RESPOND_NORMALLY로 설정합니다.
  5. 변경사항 저장을 클릭합니다.

a1a0f9d3e55ea8da.png

인앱 구매 구성

이제 앱 내에서 구매할 수 있는 상품을 구성해 보겠습니다.

App Store에서와 마찬가지로 세 가지 구매를 정의해야 합니다.

  • dash_consumable_2k: 여러 번 구매할 수 있는 소비성 구매로, 사용자에게 구매당 2,000개의 Dash (인앱 통화)가 제공됩니다.
  • dash_upgrade_3d: 비소비성 '업그레이드' 사용자가 클릭할 수 있는 외관상 다른 Dash를 제공합니다.
  • dash_subscription_doubler: 구독 기간 동안 사용자에게 클릭당 2배 많은 대시를 부여하는 구독입니다.

먼저 소비성 및 비소비성 항목을 추가합니다.

  1. Google Play Console로 이동하여 애플리케이션을 선택합니다.
  2. 수익 창출 > 제품 > 인앱 상품
  3. 제품 만들기를 클릭합니다.c8d66e32f57dee21.png
  4. 제품에 대한 모든 필수 정보를 입력합니다. 제품 ID가 사용하려는 ID와 정확하게 일치하는지 확인합니다.
  5. 저장을 클릭합니다.
  6. 활성화를 클릭합니다.
  7. 비소비성 '업그레이드'에 대해 이 프로세스를 반복합니다. 있습니다.

그런 다음 구독을 추가합니다.

  1. Google Play Console로 이동하여 애플리케이션을 선택합니다.
  2. 수익 창출 > 제품 > 정기 결제.
  3. 구독 만들기를 클릭합니다.32a6a9eefdb71dd0.png
  4. 구독에 필요한 모든 정보를 입력합니다. 제품 ID가 사용하려는 ID와 정확하게 일치하는지 확인합니다.
  5. 저장을 클릭합니다.

이제 Play Console에서 구매 항목을 설정할 수 있습니다.

6. Firebase 설정

이 Codelab에서는 백엔드 서비스를 사용하여 사용자의 요청을 확인하고 추적합니다. 구매.

백엔드 서비스를 사용하면 다음과 같은 몇 가지 이점이 있습니다.

  • 안전하게 거래를 확인할 수 있습니다.
  • 앱 스토어의 결제 이벤트에 반응할 수 있습니다.
  • 데이터베이스에서 구매 내역을 추적할 수 있습니다.
  • 사용자가 시스템 시계를 되감아 앱이 프리미엄 기능을 제공하도록 속일 수 없습니다.

백엔드 서비스를 설정하는 방법에는 여러 가지가 있지만 Google의 자체 Firebase를 사용하여 Cloud Functions와 Firestore를 사용하여 설정합니다.

백엔드 작성은 이 Codelab의 범위를 벗어나는 것으로 간주되므로 시작 코드에는 이미 기본적인 구매를 처리하여 시작하는 데 도움이 되는 Firebase 프로젝트가 포함되어 있습니다.

Firebase 플러그인은 시작 앱에도 포함되어 있습니다.

이제 Firebase 프로젝트를 직접 만들고 Firebase용 앱과 백엔드를 모두 구성한 후 마지막으로 백엔드를 배포하기만 하면 됩니다.

Firebase 프로젝트 만들기

Firebase Console로 이동하여 새 Firebase 프로젝트를 만듭니다. 이 예에서는 프로젝트의 이름을 Dash Clicker라고 지정합니다.

백엔드 앱에서는 구매를 특정 사용자와 연결하므로 인증이 필요합니다. 이를 위해 Firebase의 인증 모듈과 Google 로그인을 활용합니다.

  1. Firebase 대시보드에서 인증으로 이동하여 필요한 경우 사용 설정합니다.
  2. 로그인 방법 탭으로 이동하여 Google 로그인 제공업체를 사용 설정합니다.

7babb48832fbef29.png

Firebase의 Firestore 데이터베이스도 사용하게 되므로 이 항목도 사용 설정합니다.

e20553e0de5ac331.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
    }
  }
}

Flutter용 Firebase 설정

Flutter 앱에 Firebase를 설치할 때 권장되는 방법은 FlutterFire CLI를 사용하는 것입니다. 설정 페이지에 설명된 안내를 따릅니다.

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>  

그런 다음 두 플랫폼을 선택하여 iOSAndroid를 사용 설정합니다.

? 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                                                                                                                         

Android용 Firebase 설정: 추가 단계

Firebase 대시보드에서 프로젝트 개요로 이동하고 설정을 선택한 다음 일반 탭을 선택합니다.

내 앱까지 아래로 스크롤한 다음 dashclicker (Android) 앱을 선택합니다.

b22d46a759c0c834.png

디버그 모드에서 Google 로그인을 허용하려면 디버그 인증서의 SHA-1 해시 지문을 제공해야 합니다.

디버그 서명 인증서 해시 가져오기

Flutter 앱 프로젝트의 루트에서 디렉터리를 android/ 폴더로 변경한 후 서명 보고서를 생성합니다.

cd android
./gradlew :app:signingReport

방대한 서명 키 목록이 표시됩니다. 디버그 인증서의 해시를 찾고 있으므로 VariantConfig 속성이 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 해시를 복사하고 앱 제출 모달 대화상자의 마지막 필드를 작성합니다.

iOS용 Firebase 설정: 추가 단계

Xcodeios/Runnder.xcworkspace를 엽니다. 또는 원하는 IDE를 사용할 수도 있습니다.

VSCode에서 ios/ 폴더를 마우스 오른쪽 버튼으로 클릭한 다음 open in xcode를 클릭합니다.

Android 스튜디오에서 ios/ 폴더를 마우스 오른쪽 버튼으로 클릭하고 flutter, open iOS module in Xcode 옵션을 차례로 클릭합니다.

iOS에서 Google 로그인을 허용하려면 빌드 plist 파일에 CFBundleURLTypes 구성 옵션을 추가합니다. 자세한 내용은 google_sign_in 패키지 문서를 참고하세요. 이 경우 파일은 ios/Runner/Info-Debug.plistios/Runner/Info-Release.plist입니다.

키-값 쌍이 이미 추가되었지만 그 값을 다음과 같이 바꿔야 합니다.

  1. 주변의 <string>..</string> 요소 없이 GoogleService-Info.plist 파일에서 REVERSED_CLIENT_ID 값을 가져옵니다.
  2. CFBundleURLTypes 키 아래에서 ios/Runner/Info-Debug.plistios/Runner/Info-Release.plist 파일의 값을 모두 바꿉니다.
<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. 구매 관련 업데이트 듣기

Codelab의 이 부분에서는 제품 구매를 위한 앱을 준비합니다. 이 프로세스에는 앱 시작 후 구매 업데이트 및 오류 수신 대기가 포함됩니다.

구매 관련 최신 소식 듣기

main.dart,에서 두 페이지가 포함된 BottomNavigationBar가 있는 Scaffold가 있는 MyHomePage 위젯을 찾습니다. 이 페이지에서는 DashCounter, DashUpgrades,, DashPurchases에 관한 세 개의 Provider도 만듭니다. DashCounter는 현재 대시 수를 추적하고 이를 자동으로 증가시킵니다. DashUpgrades는 Dashes로 구매할 수 있는 업그레이드를 관리합니다. 이 Codelab에서는 DashPurchases에 중점을 둡니다.

기본적으로 제공자의 객체는 해당 객체가 처음 요청될 때 정의됩니다. 이 객체는 앱이 시작될 때 직접 구매 업데이트를 리슨하므로 lazy: false를 사용하여 이 객체에서 지연 로드를 사용 중지합니다.

lib/main.dart

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

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!;
  }
}

테스트를 계속 실행하려면 테스트를 약간 업데이트해야 합니다. TestIAPConnection의 전체 코드는 GitHub의 widget_test.dart를 확인합니다.

test/widget_test.dart

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

lib/logic/dash_purchases.dart에서 DashPurchases ChangeNotifier 코드로 이동합니다. 현재 구매한 Dash에 추가할 수 있는 DashCounter 개만 있습니다.

스트림 구독 속성 _subscription (StreamSubscription<List<PurchaseDetails>> _subscription; 유형), IAPConnection.instance,, 가져오기를 추가합니다. 결과 코드는 다음과 같습니다.

lib/logic/dash_purchases.dart

import 'package:in_app_purchase/in_app_purchase.dart';

class DashPurchases extends ChangeNotifier {
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  final iapConnection = IAPConnection.instance;

  DashPurchases(this.counter);
}

_subscription가 생성자에서 초기화되었으므로 late 키워드가 _subscription에 추가됩니다. 이 프로젝트는 기본적으로 null을 허용하지 않도록 설정되어 있습니다 (NNBD). 즉, null을 허용하는 것으로 선언되지 않은 속성은 null이 아닌 값을 가져야 합니다. late 한정자를 사용하면 이 값의 정의를 지연할 수 있습니다.

생성자에서 purchaseUpdatedStream를 가져와서 스트림 수신 대기를 시작합니다. dispose() 메서드에서 스트림 구독을 취소합니다.

lib/logic/dash_purchases.dart

class DashPurchases extends ChangeNotifier {
  DashCounter counter;
  late StreamSubscription<List<PurchaseDetails>> _subscription;
  final iapConnection = IAPConnection.instance;

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

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

  Future<void> buy(PurchasableProduct product) async {
    // omitted
  }

  void _onPurchaseUpdate(List<PurchaseDetails> purchaseDetailsList) {
    // Handle purchases here
  }

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

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

이제 앱이 구매 업데이트를 수신하므로 다음 섹션에서 구매합니다.

계속하기 전에 "flutter test""로 테스트를 실행하여 모든 것이 올바르게 설정되었는지 확인합니다.

$ flutter test

00:01 +1: All tests passed!                                                                                   

8. 구매

Codelab의 이 부분에서는 현재 기존 모의 제품을 실제 구매 가능한 제품으로 바꿉니다. 이러한 제품은 목록에서 로드되어 표시되며 제품을 탭하면 구매됩니다.

Adapt PurchasableProduct

PurchasableProduct는 모의 제품을 표시합니다. purchasable_product.dartPurchasableProduct 클래스를 다음 코드로 바꿔 실제 콘텐츠를 표시하도록 업데이트합니다.

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 = [];)으로 바꿉니다.

구매 가능한 구매 항목 로드하기

사용자가 구매할 수 있도록 하려면 스토어에서 구매 항목을 로드합니다. 먼저 매장의 이용 가능 여부를 확인합니다. 스토어를 사용할 수 없는 경우 storeStatenotAvailable로 설정하면 사용자에게 오류 메시지가 표시됩니다.

lib/logic/dash_purchases.dart

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

스토어를 사용할 수 있게 되면 구매 가능한 항목을 로드합니다. 이전 Firebase 설정을 고려할 때 storeKeyConsumable, storeKeySubscription,, storeKeyUpgrade가 표시될 것으로 예상됩니다. 예상 구매를 확인할 수 없는 경우 이 정보를 콘솔에 출력합니다. 이 정보를 백엔드 서비스로 보내는 것이 좋습니다.

await iapConnection.queryProductDetails(ids) 메서드는 찾을 수 없는 ID와 발견된 구매 가능한 제품을 모두 반환합니다. 응답의 productDetails를 사용하여 UI를 업데이트하고 StoreStateavailable로 설정합니다.

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);
    for (var element in response.notFoundIDs) {
      debugPrint('Purchase $element not found');
    }
    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();
  }

마지막으로 storeState 필드 값을 StoreState.available에서 StoreState.loading:로 변경합니다.

lib/logic/dash_purchases.dart

StoreState storeState = StoreState.loading;

구매 가능한 제품 표시

purchase_page.dart 파일을 살펴보겠습니다. PurchasePage 위젯은 StoreState에 따라 _PurchasesLoading, _PurchaseList, 또는 _PurchasesNotAvailable,를 표시합니다. 위젯에는 다음 단계에서 사용되는 사용자의 이전 구매도 표시됩니다.

_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);
        break;
      case storeKeySubscription:
      case storeKeyUpgrade:
        await iapConnection.buyNonConsumable(purchaseParam: purchaseParam);
        break;
      default:
        throw ArgumentError.value(
            product.productDetails, '${product.id} is not a known product');
    }
  }

계속하기 전에 _beautifiedDashUpgrade 변수를 만들고 이 변수를 참조하도록 beautifiedDash getter를 업데이트합니다.

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();
          break;
        case storeKeyConsumable:
          counter.addBoughtDashes(2000);
          break;
        case storeKeyUpgrade:
          _beautifiedDashUpgrade = true;
          break;
      }
    }

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

9. 백엔드 설정

구매 추적 및 확인으로 이동하기 전에 이를 지원하도록 Dart 백엔드를 설정합니다.

이 섹션에서는 dart-backend/ 폴더에서 루트로 작업합니다.

다음 도구가 설치되어 있는지 확인하세요.

기본 프로젝트 개요

이 프로젝트의 일부는 이 Codelab의 범위를 벗어나는 것으로 간주되므로 시작 코드에 포함되어 있습니다. 시작하기 전에 시작 코드에 있는 내용을 살펴보고 구성 방법을 파악하는 것이 좋습니다.

이 백엔드 코드는 머신에서 로컬로 실행할 수 있으며 사용하기 위해 배포할 필요가 없습니다. 그러나 개발 기기 (Android 또는 iPhone)에서 서버가 실행될 컴퓨터에 연결할 수 있어야 합니다. 이를 위해서는 기기가 동일한 네트워크에 있어야 하며 머신의 IP 주소를 알아야 합니다.

다음 명령어를 사용하여 서버를 실행해 봅니다.

$ dart ./bin/server.dart

Serving at http://0.0.0.0:8080

Dart 백엔드는 shelfshelf_router를 사용하여 API 엔드포인트를 제공합니다. 기본적으로 서버는 경로를 제공하지 않습니다. 나중에 구매 인증 프로세스를 처리할 경로를 만듭니다.

시작 코드에 이미 포함된 한 부분은 lib/iap_repository.dartIapRepository입니다. Firestore 또는 일반적인 데이터베이스와 상호작용하는 방법을 배우는 것은 이 Codelab과 관련이 없는 것으로 여겨집니다. 따라서 시작 코드에는 Firestore에서 구매를 만들거나 업데이트하는 데 사용할 수 있는 함수와 이러한 구매의 모든 클래스가 포함되어 있습니다.

Firebase 액세스 설정

Firebase Firestore에 액세스하려면 서비스 계정 액세스 키가 필요합니다. Firebase 프로젝트 설정을 여는 비공개 키를 생성하고 서비스 계정 섹션으로 이동한 다음 새 비공개 키 생성을 선택합니다.

27590fc77ae94ad4.png

다운로드한 JSON 파일을 assets/ 폴더에 복사하고 이름을 service-account-firebase.json로 바꿉니다.

Google Play 액세스 설정

구매를 인증하기 위해 Play 스토어에 액세스하려면 이러한 권한을 가진 서비스 계정을 생성하고 JSON 사용자 인증 정보를 다운로드해야 합니다.

  1. Google Play Console로 이동하여 모든 앱 페이지에서 시작합니다.
  2. 설정 > API 액세스 317fdfb54921f50e.png Google Play Console에서 프로젝트를 만들거나 기존 프로젝트에 연결하도록 요청하는 경우 먼저 프로젝트를 만들거나 이 페이지로 돌아오세요.
  3. 서비스 계정을 정의할 수 있는 섹션을 찾아 새 서비스 계정 만들기를 클릭합니다.1e70d3f8d794bebb.png
  4. 팝업 대화상자에서 Google Cloud Platform 링크를 클릭합니다. <ph type="x-smartling-placeholder">7c9536336dd9e9b4.png</ph>
  5. 프로젝트를 선택합니다. Google 계정이 표시되지 않으면 오른쪽 상단의 계정 드롭다운 목록에서 올바른 Google 계정에 로그인했는지 확인합니다. <ph type="x-smartling-placeholder">3fb3a25bad803063.png</ph>
  6. 프로젝트를 선택한 후 상단 메뉴 바에서 + 서비스 계정 만들기를 클릭합니다. <ph type="x-smartling-placeholder">62fe4c3f8644acd8.png</ph>
  7. 서비스 계정의 이름을 입력하고 필요한 경우 기억할 수 있도록 설명을 입력한 후 다음 단계로 이동합니다. 8a92d5d6a3dff48c.png
  8. 서비스 계정에 편집자 역할을 할당합니다. <ph type="x-smartling-placeholder">6052b7753667ed1a.png</ph>
  9. 마법사를 종료하고 개발자 콘솔의 API 액세스 페이지로 돌아가서 서비스 계정 새로고침을 클릭합니다. 새로 만든 계정이 목록에 표시됩니다. 5895a7db8b4c7659.png
  10. 새 서비스 계정에 대한 액세스 권한 부여를 클릭합니다.
  11. 다음 페이지에서 아래로 스크롤하여 재무 데이터 블록으로 이동합니다. 재무 데이터, 주문, 취소 설문조사 응답 보기주문 및 구독 관리를 모두 선택합니다. <ph type="x-smartling-placeholder">75b22d0201cf67e.png</ph>
  12. 사용자 초대를 클릭합니다. <ph type="x-smartling-placeholder">70ea0b1288c62a59.png</ph>
  13. 이제 계정이 설정되었으므로 사용자 인증 정보를 생성하기만 하면 됩니다. Cloud 콘솔로 돌아가 서비스 계정 목록에서 서비스 계정을 찾아 세로로 나열된 점 3개를 클릭한 후 키 관리를 선택합니다. <ph type="x-smartling-placeholder">853ee186b0e9954e.png</ph>
  14. 새 JSON 키를 만들고 다운로드합니다. 2a33a55803f5299c.png cb4bf48ebac0364e.png
  15. 다운로드한 파일의 이름을 service-account-google-play.json,로 바꾸고 assets/ 디렉터리로 이동합니다.

한 가지 더 해야 할 일은 lib/constants.dart,를 열고 androidPackageId 값을 Android 앱에 선택한 패키지 ID로 바꾸는 것입니다.

Apple App Store 액세스 설정

구매를 인증하기 위해 App Store에 액세스하려면 공유 비밀번호를 설정해야 합니다.

  1. App Store Connect를 엽니다.
  2. 내 앱으로 이동하여 앱을 선택합니다.
  3. 사이드바 탐색 메뉴에서 인앱 구매 > 관리를 클릭합니다.
  4. 목록 오른쪽 상단에서 App-Specific Shared Secret(앱별 공유 비밀번호)을 클릭합니다.
  5. 새 보안 비밀을 생성하고 복사합니다.
  6. lib/constants.dart,를 열고 appStoreSharedSecret 값을 방금 생성한 공유 보안 비밀로 바꿉니다.

d8b8042470aaeff.png

b72f4565750e2f40.png

상수 구성 파일

계속하기 전에 lib/constants.dart 파일에 다음 상수가 구성되어 있는지 확인합니다.

  • androidPackageId: Android에서 사용되는 패키지 ID입니다. 예: com.example.dashclicker
  • appStoreSharedSecret: 구매 인증을 수행하기 위해 App Store Connect에 액세스하는 공유 비밀번호입니다.
  • bundleId: iOS에서 사용되는 번들 ID입니다. 예: com.example.dashclicker

당분간 나머지 상수는 무시해도 됩니다.

10. 구매 인증

구매를 인증하는 일반적인 과정은 iOS와 Android가 비슷합니다.

두 상점 모두 구매가 이루어질 때 애플리케이션에서 토큰을 수신합니다.

이 토큰은 앱에서 백엔드 서비스로 전송된 다음 백엔드 서비스에서 제공된 토큰을 사용하여 각 매장의 서버에서 구매를 확인합니다.

그러면 백엔드 서비스는 구매를 저장하고 구매가 유효한지 여부를 애플리케이션에 회신할 수 있습니다.

백엔드 서비스가 사용자 기기에서 실행되는 애플리케이션이 아닌 스토어에서 유효성 검사를 수행하도록 하면 시스템 시계를 되감는 등의 방법으로 사용자가 프리미엄 기능에 액세스하는 것을 방지할 수 있습니다.

Flutter 측 설정

인증을 설정합니다.

백엔드 서비스로 구매를 전송하게 되므로 구매하는 동안 사용자가 인증되었는지 확인하려고 합니다. 대부분의 인증 로직은 시작 프로젝트에 이미 추가되어 있으므로 사용자가 아직 로그인하지 않았을 때 PurchasePage에 로그인 버튼이 표시되는지 확인하기만 하면 됩니다. PurchasePage의 빌드 메서드 시작 부분에 다음 코드를 추가합니다.

lib/pages/purchase_page.dart

import '../logic/firebase_notifier.dart';
import '../model/firebase_state.dart';
import 'login_page.dart';

class PurchasePage extends StatelessWidget {  
  const PurchasePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    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();
    }
    // omitted

앱에서 통화 확인 엔드포인트

앱에서 http 사후 호출을 사용하여 Dart 백엔드에서 /verifypurchase 엔드포인트를 호출하는 _verifyPurchase(PurchaseDetails purchaseDetails) 함수를 만듭니다.

선택한 스토어 (Play 스토어의 경우 google_play, App Store의 경우 app_store), serverVerificationData, productID을 전송합니다. 서버에서 구매가 인증되었는지 여부를 나타내는 상태 코드를 반환합니다.

앱 상수에서 서버 IP를 로컬 머신 IP 주소로 구성합니다.

lib/logic/dash_purchases.dart

  FirebaseNotifier firebaseNotifier;

  DashPurchases(this.counter, this.firebaseNotifier) {
    // omitted
  }

main.dart:에서 DashPurchases를 생성하여 firebaseNotifier를 추가합니다.

lib/main.dart

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

사용자 ID를 Verify purchase 함수에 전달할 수 있도록 FirebaseNotifier에서 사용자에 대한 getter를 추가합니다.

lib/logic/firebase_notifier.dart

  User? get user => FirebaseAuth.instance.currentUser;

_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) {
      print('Successfully verified purchase');
      return true;
    } else {
      print('failed request: ${response.statusCode} - ${response.body}');
      return false;
    }
  }

구매를 적용하기 직전에 _handlePurchase에서 _verifyPurchase 함수를 호출합니다. 인증이 완료된 후에만 구매를 적용해야 합니다. 프로덕션 앱에서는 이를 추가로 지정하여 스토어를 일시적으로 사용할 수 없을 때 무료 체험판 정기 결제를 적용할 수 있습니다. 하지만 이 예시에서는 단순하게 유지하고 구매가 성공적으로 확인된 경우에만 구매를 적용하세요.

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();
            break;
          case storeKeyConsumable:
            counter.addBoughtDashes(1000);
            break;
        }
      }
    }

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

이제 앱에서 모든 것이 구매를 검증할 준비가 되었습니다.

백엔드 서비스 설정

다음으로 백엔드에서 구매를 확인하도록 Cloud 함수를 설정합니다.

구매 핸들러 빌드

두 스토어의 인증 흐름이 거의 동일하므로 각 매장에 별도의 구현을 사용하여 추상적인 PurchaseHandler 클래스를 설정합니다.

be50c207c5a2a519.png

먼저 lib/ 폴더에 purchase_handler.dart 파일을 추가합니다. 여기서는 정기 결제와 비정기 결제라는 두 가지 구매 유형을 확인하는 두 가지 추상적인 메서드가 있는 추상 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:: 구매 항목을 사용자와 연결할 수 있는 로그인한 사용자의 ID입니다.
  • 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 클래스에는 제품 ID (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 스토어 및 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,
  }) {
    return true;
  }

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

좋습니다. 이제 구매 핸들러가 두 개입니다. 다음으로 구매 확인 API 엔드포인트를 만들어 보겠습니다.

구매 핸들러 사용

bin/server.dart를 열고 shelf_route를 사용하여 API 엔드포인트를 만듭니다.

bin/server.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);
}

({
  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 페이로드를 디코딩하고 다음 정보를 추출합니다.
  3. userId: 현재 로그인한 사용자 ID
  4. source: 사용된 저장소(app_store 또는 google_play)
  5. productData: 이전에 만든 productDataMap에서 가져옵니다.
  6. token: 매장으로 전송할 인증 데이터가 포함됩니다.
  7. 소스에 따라 GooglePlayPurchaseHandler 또는 AppStorePurchaseHandler에서 verifyPurchase 메서드를 호출합니다.
  8. 확인에 성공하면 메서드가 클라이언트에 Response.ok를 반환합니다.
  9. 확인에 실패하면 메서드는 클라이언트에 Response.internalServerError를 반환합니다.

API 엔드포인트를 만든 후에는 두 개의 구매 핸들러를 구성해야 합니다. 이렇게 하려면 이전 단계에서 가져온 서비스 계정 키를 로드하고 Android Publisher API 및 Firebase Firestore API를 비롯한 다양한 서비스에 대한 액세스를 구성해야 합니다. 그런 다음 종속 항목이 서로 다른 두 개의 구매 핸들러를 만듭니다.

bin/server.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은 구매 확인에 필요한 API와 상호작용하기 위한 Dart 패키지를 이미 제공하고 있습니다. server.dart 파일에서 이를 초기화하고 이제 GooglePlayPurchaseHandler 클래스에서 사용합니다.

정기 결제 유형이 아닌 구매를 위한 핸들러를 구현합니다.

lib/google_play_purchase_handler.dart

  @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 do not 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 do not 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;
  }
}

주문 ID를 파싱하는 데 도움이 되는 다음 메서드와 구매 상태를 파싱하는 두 가지 메서드를 추가합니다.

lib/google_play_purchase_handler.dart

/// 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;
}

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,
  };
}

이제 Google Play 구매 내역이 인증되고 데이터베이스에 저장됩니다.

다음으로 iOS용 App Store 구매로 이동합니다.

iOS 구매 확인: 구매 핸들러 구현

App Store에서 구매를 인증하기 위해 프로세스를 더 쉽게 만들어 주는 app_store_server_sdk라는 서드 파티 Dart 패키지가 있습니다.

먼저 ITunesApi 인스턴스를 만듭니다. 샌드박스 구성을 사용하고 로깅을 사용 설정하여 오류 디버깅을 용이하게 합니다.

lib/app_store_purchase_handler.dart

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

이제 Google Play API와 달리 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 {
   //..
  }

이제 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 스토어와 Apple App Store 모두에 대해 이를 설정합니다.

Google Play 결제 이벤트 처리하기

Google Play에서는 Cloud 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;

그런 다음 PubsubApiGooglePlayPurchaseHandler에 전달하고 다음과 같이 클래스 생성자를 수정하여 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는 10초마다 _pullMessageFromSubSub 메서드를 호출하도록 구성되어 있습니다. 원하는 대로 길이를 조정할 수 있습니다.

그런 다음 _pullMessageFromSubSub를 만듭니다.

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/$googlePlayProjectName/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/$googlePlayProjectName/subscriptions/$googlePlayPubsubBillingTopic-sub';
    await pubsubApi.projects.subscriptions.acknowledge(
      request,
      subscriptionName,
    );
  }

방금 추가한 코드는 10초마다 Google Cloud의 Pub/Sub 주제와 통신하며 새 메시지를 요청합니다. 그런 다음 _processMessage 메서드에서 각 메시지를 처리합니다.

이 메서드는 수신 메시지를 디코딩하고 정기 결제 및 비정기 결제의 각 구매에 관한 업데이트된 정보를 가져와 필요한 경우 기존 handleSubscription 또는 handleNonSubscription를 호출합니다.

각 메시지는 _askMessage 메서드로 확인해야 합니다.

그런 다음 필요한 종속 항목을 server.dart 파일에 추가합니다. 사용자 인증 정보 구성에 PubsubApi.cloudPlatformScope를 추가합니다.

bin/server.dart

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

그런 다음 PubsubApi 인스턴스를 만듭니다.

bin/server.dart

  final pubsubApi = pubsub.PubsubApi(clientGooglePlay);

마지막으로 GooglePlayPurchaseHandler 생성자에 전달합니다.

bin/server.dart

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

Google Play 설정

Pub/Sub 주제의 결제 이벤트를 소비하는 코드를 작성했지만 Pub/Sub 주제를 만들지 않았거나 결제 이벤트를 게시하지 않았습니다. 이제 이를 설정할 차례입니다.

먼저 Pub/Sub 주제를 만듭니다.

  1. Google Cloud 콘솔에서 Cloud Pub/Sub 페이지로 이동합니다.
  2. Firebase 프로젝트에 열려 있는지 확인하고 + 주제 만들기를 클릭합니다. <ph type="x-smartling-placeholder">d5ebf6897a0a8bf5.png</ph>
  3. constants.tsGOOGLE_PLAY_PUBSUB_BILLING_TOPIC에 설정된 값과 동일한 이름을 새 주제에 지정합니다. 여기서는 이름을 play_billing로 지정합니다. 다른 항목을 선택하는 경우 constants.ts을(를) 업데이트하세요. 주제를 만듭니다. 20d690fc543c4212.png
  4. 게시/구독 주제 목록에서 방금 만든 주제의 세로 점 3개를 클릭하고 권한 보기를 클릭합니다. <ph type="x-smartling-placeholder">ea03308190609fb.png</ph>
  5. 오른쪽 사이드바에서 주 구성원 추가를 선택합니다.
  6. 여기에서 google-play-developer-notifications@system.gserviceaccount.com를 추가하고 Pub/Sub 게시자 역할을 부여합니다. 55631ec0549215bc.png
  7. 권한 변경사항을 저장합니다.
  8. 방금 만든 주제의 주제 이름을 복사합니다.
  9. Play Console을 다시 열고 모든 앱 목록에서 앱을 선택합니다.
  10. 아래로 스크롤하여 수익 창출 > 수익 창출 설정
  11. 전체 주제를 입력하고 변경사항을 저장합니다. 7e5e875dc6ce5d54.png

이제 모든 Google Play 결제 이벤트가 이 주제에 게시됩니다.

App Store 결제 이벤트 처리하기

다음으로, App Store 결제 이벤트에도 동일한 작업을 수행합니다. App Store에서 구매의 업데이트 처리를 구현하는 효과적인 방법에는 두 가지가 있습니다. 하나는 Apple에 제공하고 서버와 통신하는 데 사용하는 웹훅을 구현하는 것입니다. 이 Codelab에서 찾을 두 번째 방법은 App Store Server API에 연결하고 수동으로 정기 결제 정보를 가져오는 것입니다.

이 Codelab에서 두 번째 솔루션에 중점을 두는 이유는 웹훅을 구현하려면 서버를 인터넷에 노출해야 하기 때문입니다.

프로덕션 환경에서는 둘 다 있는 것이 좋습니다. App Store에서 이벤트를 가져오는 웹훅 및 이벤트를 놓쳤거나 구독 상태를 다시 확인해야 하는 경우 Server API

먼저 lib/app_store_purchase_handler.dart를 열고 AppStoreServerAPI 종속 항목을 추가합니다.

lib/app_store_purchase_handler.dart

final AppStoreServerAPI appStoreServerAPI;

AppStorePurchaseHandler(
  this.iapRepository,
  this.appStoreServerAPI, // new
)

생성자를 수정하여 _pullStatus 메서드를 호출할 타이머를 추가합니다. 이 타이머는 10초마다 _pullStatus 메서드를 호출합니다. 이 타이머 시간은 필요에 따라 조정할 수 있습니다.

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

  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. IapRepository를 사용하여 Firestore에서 활성 구독 목록을 가져옵니다.
  2. 각 주문에 대해 App Store Server API에 구독 상태를 요청합니다.
  3. 해당 정기 결제 구매에 대한 마지막 트랜잭션을 가져옵니다.
  4. 만료일을 확인합니다.
  5. Firestore에서 구독 상태를 업데이트합니다. 만료된 경우 구독 상태로 표시됩니다.

마지막으로 필요한 모든 코드를 추가하여 App Store Server API 액세스를 구성합니다.

bin/server.dart

  // 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, // new
    ),
  };

App Store 설정

다음으로 App Store를 설정합니다.

  1. App Store Connect에 로그인하고 사용자 및 액세스를 선택합니다.
  2. 키 유형 > 인앱 구매.
  3. '더하기'를 탭합니다. 아이콘을 클릭하여 새 항목을 추가합니다.
  4. 이름을 지정합니다. 예: 'Codelab 키'.
  5. 키가 포함된 p8 파일을 다운로드합니다.
  6. 이름을 SubscriptionKey.p8로 애셋 폴더에 복사합니다.
  7. 새로 만든 키에서 키 ID를 복사하고 lib/constants.dart 파일에서 appStoreKeyId 상수로 설정합니다.
  8. 키 목록 상단에서 발급기관 ID를 복사하고 lib/constants.dart 파일에서 이를 appStoreIssuerId 상수로 설정합니다.

9540ea9ada3da151.png

기기에서 구매 추적하기

구매를 추적하는 가장 안전한 방법은 서버 측에 있습니다. 클라이언트가 보호하기 어렵기 때문입니다. 하지만 앱이 구독 상태 정보에 따라 조치를 취할 수 있도록 정보를 클라이언트에 다시 가져올 방법이 있어야 합니다. 구매 내역을 Firestore에 저장하면 데이터를 클라이언트와 쉽게 동기화하고 자동으로 업데이트할 수 있습니다.

List<PastPurchase> purchases에 사용자의 구매 데이터가 모두 포함된 Firestore 저장소인 IAPRepo가 앱에 이미 포함되어 있습니다. 저장소에는 만료되지 않은 상태의 productId storeKeySubscription 구매가 있으면 true인 hasActiveSubscription,도 포함됩니다. 사용자가 로그인하지 않은 경우 목록은 비어 있습니다.

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((DocumentSnapshot 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() 메서드에서 리스너를 삭제합니다. 처음에는 리스너가 빈 함수일 수 있습니다. IAPRepoChangeNotifier이고 Firestore에서의 구매가 변경될 때마다 notifyListeners()를 호출하므로 구매한 제품이 변경될 때마다 항상 purchasesUpdate() 메서드가 호출됩니다.

lib/logic/dash_purchases.dart

  IAPRepo iapRepo;

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

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

  void purchasesUpdate() {
    //TODO manage updates
  }

다음으로, main.dart.의 생성자에 IAPRepo를 제공합니다. 이미 Provider에 생성되었으므로 context.read를 사용하여 저장소를 가져올 수 있습니다.

lib/main.dart

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

다음으로 purchaseUpdate() 함수의 코드를 작성합니다. dash_counter.dart,에서 applyPaidMultiplierremovePaidMultiplier 메서드는 각각 승수를 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. 완료

수고하셨습니다. Codelab을 완료했습니다. 이 Codelab의 완료된 코드는 android_studio_folder.pngcomplete 폴더에서 확인할 수 있습니다.

자세한 내용은 다른 Flutter Codelab을 참고하세요.