הוספת רכישות מתוך האפליקציה לאפליקציית Flutter

1. מבוא

כדי להוסיף רכישות מתוך האפליקציה לאפליקציית Flutter, צריך להגדיר נכון את חנויות האפליקציות (App Store ו-Play Store), לאמת את הרכישה ולהעניק את ההרשאות הנדרשות, כמו הטבות למנויים.

ב-codelab הזה תוסיפו לאפליקציה (שמסופקת לכם) שלושה סוגים של רכישות מתוך האפליקציה, ותאמתו את הרכישות האלה באמצעות קצה עורפי של Dart עם Firebase. האפליקציה שצוינה, Dash Clicker, מכילה משחק שמשתמש בקמע Dash כמטבע. תוסיפו את אפשרויות הרכישה הבאות:

  1. אפשרות רכישה חוזרת של 2,000 Dash בבת אחת.
  2. רכישת שדרוג חד-פעמי כדי לשנות את לוח הבקרה הישן ללוח בקרה מודרני.
  3. מינוי שמכפיל את מספר הקליקים שנוצרים אוטומטית.

האפשרות הראשונה לרכישה מעניקה למשתמש הטבה ישירה של 2,000 Dash. הם זמינים ישירות למשתמש ואפשר לקנות אותם הרבה פעמים. המוצר הזה נקרא מוצר מתכלה כי הוא נצרך ישירות ואפשר לצרוך אותו כמה פעמים.

האפשרות השנייה משדרגת את ה-Dash ל-Dash יפה יותר. צריך לרכוש את המינוי הזה רק פעם אחת, והוא זמין לתמיד. רכישה כזו נקראת רכישה לא מתכלה כי אי אפשר לצרוך אותה באפליקציה, אבל היא תקפה לנצח.

אפשרות הרכישה השלישית והאחרונה היא מינוי. בזמן שהמינוי פעיל, המשתמש יקבל את המקפים מהר יותר, אבל כשהוא יפסיק לשלם על המינוי, הוא יאבד את ההטבות.

השירות לקצה העורפי (שמסופק גם הוא) פועל כאפליקציית Dart, מאמת שהרכישות בוצעו ומאחסן אותן באמצעות Firestore. השתמשנו ב-Firestore כדי להקל על התהליך, אבל באפליקציה שלכם בסביבת הייצור אתם יכולים להשתמש בכל סוג של שירות backend.

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

מה תפַתחו

  • תלמדו איך להרחיב אפליקציה כדי לתמוך ברכישות של מוצרים מתכלים ובמינויים.
  • בנוסף, תרחיבו אפליקציית Dart backend כדי לאמת ולאחסן את הפריטים שנרכשו.

מה תלמדו

  • איך מגדירים את App Store ואת חנות Play עם מוצרים שאפשר לרכוש.
  • איך מתקשרים עם החנויות כדי לאמת רכישות ולאחסן אותן ב-Firestore.
  • איך לנהל רכישות באפליקציה.

מה צריך?

  • Android Studio
  • ‫Xcode (לפיתוח ל-iOS)
  • Flutter SDK

2. הגדרת סביבת הפיתוח

כדי להתחיל את ה-codelab הזה, מורידים את הקוד ומשנים את מזהה החבילה ל-iOS ואת שם החבילה ל-Android.

הורדת הקוד

כדי לשכפל את מאגר GitHub משורת הפקודה, משתמשים בפקודה הבאה:

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

לחלופין, אם התקנתם את הכלי GitHub's cli, אתם יכולים להשתמש בפקודה הבאה:

gh repo clone flutter/codelabs flutter-codelabs

הקוד לדוגמה משוכפל לספרייה flutter-codelabs שמכילה את הקוד של אוסף של סדנאות קוד. הקוד של ה-Codelab הזה נמצא ב-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. לוחצים לחיצה כפולה על Runner כדי לערוך את הגדרות הפרויקט, ואז לוחצים על Signing & Capabilities (חתימה ויכולות). מזינים את מזהה החבילה שבחרתם בשדה צוות כדי להגדיר את הצוות.

812f919d965c649a.jpeg

עכשיו אפשר לסגור את Xcode ולחזור אל Android Studio כדי לסיים את ההגדרה ל-Android. כדי לעשות זאת, פותחים את הקובץ build.gradle.kts בקטע android/app, ומשנים את applicationId (בשורה 24 בצילום המסך שלמטה) למזהה האפליקציה, זהה למזהה החבילה של iOS. שימו לב: המזהים של חנויות iOS ו-Android לא חייבים להיות זהים, אבל אם הם זהים יש פחות סיכוי לטעות. לכן, במעבדת הקוד הזו נשתמש גם במזהים זהים.

e320a49ff2068ac2.png

‫3. התקנת הפלאגין

בחלק הזה של ה-codelab תתקינו את הפלאגין 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 לבדיקה. כדי לעשות את זה, צריך חשבון פיתוח. אם אין לכם חשבון כזה, אתם צריכים להירשם לתוכנית המפתחים של אפל.

כדי להשתמש ברכישות מתוך האפליקציה, צריך גם הסכם פעיל לגבי אפליקציות בתשלום ב-App Store Connect. עוברים לכתובת https://appstoreconnect.apple.com/ ולוחצים על Agreements, Tax, and Banking (הסכמים, מיסים ובנקאות).

11db9fca823e7608.png

כאן יופיעו הסכמים לאפליקציות בחינם ולאפליקציות בתשלום. הסטטוס של אפליקציות חינמיות צריך להיות פעיל, והסטטוס של אפליקציות בתשלום הוא חדש. חשוב לקרוא את התנאים, לאשר אותם ולהזין את כל המידע הנדרש.

74c73197472c9aec.png

אם הכול מוגדר בצורה נכונה, הסטטוס של אפליקציות בתשלום יהיה פעיל. זה חשוב מאוד כי לא תוכלו לנסות רכישות מתוך האפליקציה בלי הסכם פעיל.

4a100bbb8cafdbbf.jpeg

רישום מזהה האפליקציה

יוצרים מזהה חדש בפורטל Apple Developer. נכנסים לכתובת 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 – הוא משמש רק לבדיקת רכישות מתוך האפליקציה. אי אפשר להשתמש בכתובת אימייל שכבר משמשת לחשבון אפל. בקטע משתמשים וגישה, עוברים אל Sandbox כדי ליצור חשבון חדש ב-Sandbox או כדי לנהל את מזהי Apple הקיימים ב-Sandbox.

2ba0f599bcac9b36.png

עכשיו אפשר להגדיר את משתמש הסביבה הווירטואלית באייפון. לשם כך, עוברים אל הגדרות > מפתח > חשבון סביבת בדיקה של אפל.

74a545210b282ad8.png eaa67752f2350f74.png

הגדרת רכישות מתוך האפליקציה

עכשיו מגדירים את שלושת הפריטים שניתן לרכוש:

  • dash_consumable_2k: רכישה מתכלה שאפשר לרכוש הרבה פעמים, שמעניקה למשתמש 2,000 יחידות Dash (המטבע באפליקציה) לכל רכישה.
  • dash_upgrade_3d: רכישה של 'שדרוג' חד-פעמי שלא ניתן לצרוך, שמעניקה למשתמש אפשרות ללחוץ על מקף שונה מבחינה ויזואלית.
  • dash_subscription_doubler: מינוי שמעניק למשתמש פי שניים דאשים לכל קליק למשך תקופת המינוי.

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 כפריט שלא ניתן לצריכה. משתמשים ב-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 בשלב הבא, בוחרים את משך המינוי (שבוע אחד) ואת הלוקליזציות. תקרא למינוי הזה Jet Engine ותוסיף את התיאור Doubles your clicks. הגדרת המחיר ל-$0.49. מגדירים את הזמינות ומעלים את צילום המסך של הביקורת באותו אופן כמו עבור המוצר dash_consumable_2k. 44d18e02b926a334.png

עכשיו המוצרים אמורים להופיע ברשימות:

17f242b5c1426b79.png d71da951f595054a.png

5. הגדרת חנות Play

כמו בחנות האפליקציות, תצטרכו גם חשבון מפתח בחנות Play. אם עדיין אין לכם חשבון, צריך להירשם.

יצירת אפליקציה חדשה

יוצרים אפליקציה חדשה ב-Google Play Console:

  1. פותחים את Play Console.
  2. בוחרים באפשרות כל האפליקציות > יצירת אפליקציה.
  3. בוחרים שפת ברירת מחדל ומוסיפים שם לאפליקציה. מקלידים את שם האפליקציה כפי שרוצים שהוא יופיע ב-Google Play. אפשר לשנות את השם בהמשך.
  4. מציינים שהאפליקציה היא משחק. אפשר לשנות את הבחירה בשלב מאוחר יותר.
  5. מציינים אם האפליקציה חינמית או בתשלום.
  6. משלימים את ההצהרות בנושא הנחיות לתוכן וחוקי הייצוא של ארה"ב.
  7. לוחצים על יצירת אפליקציה.

אחרי שיוצרים את האפליקציה, עוברים ללוח הבקרה ומשלימים את כל המשימות בקטע הגדרת האפליקציה. כאן צריך לספק מידע על האפליקציה, כמו סיווג תוכן וצילומי מסך. 13845badcf9bc1db.png

חתימה על הבקשה

כדי לבדוק רכישות מתוך האפליקציה, צריך להעלות לפחות גרסת build אחת ל-Google Play.

לשם כך, צריך לחתום על גרסת ה-release באמצעות משהו אחר מלבד מפתחות הניפוי באגים.

יצירת מאגר מפתחות

אם יש לכם מאגר מפתחות קיים, מדלגים לשלב הבא. אם לא, יוצרים אותו על ידי הרצת הפקודה הבאה בשורת הפקודה.

ב-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

file private; don't check it into public source control!

הפניה אל מאגר המפתחות מהאפליקציה

יוצרים קובץ בשם <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

הפקודה הזו יוצרת כברירת מחדל גרסת build של גרסה, והפלט נמצא ב-<your app dir>/build/app/outputs/bundle/release/

מלוח הבקרה ב-Google Play Console, עוברים אל בדיקה והשקה > בדיקה > בדיקה בקבוצה מוגדרת ויוצרים גרסת בדיקה חדשה בקבוצה מוגדרת.

לאחר מכן, מעלים את חבילת האפליקציות app-release.aab שנוצרה על ידי פקודת הבנייה.

לוחצים על שמירה ואז על בדיקת הגרסה.

לבסוף, לוחצים על השקת הגרסה לבדיקות בקבוצות מוגדרות כדי להפעיל את הגרסה לבדיקות בקבוצות מוגדרות.

הגדרת משתמשי בדיקה

כדי לבדוק רכישות מתוך האפליקציה, צריך להוסיף את חשבונות Google של הבודקים בשני מקומות ב-Google Play Console:

  1. למסלול הבדיקה הספציפי (בדיקה פנימית)
  2. כבודקי רישיונות

קודם כול, מוסיפים את הבודק למסלול הבדיקה הפנימית. חוזרים אל בדיקה ופרסום > בדיקה > בדיקה פנימית ולוחצים על הכרטיסייה בודקים.

a0d0394e85128f84.png

כדי ליצור רשימה חדשה של כתובות אימייל, לוחצים על יצירת רשימת כתובות אימייל. נותנים לרשימה שם ומוסיפים את כתובות האימייל של חשבונות Google שצריכים לקבל גישה לבדיקת רכישות מתוך האפליקציה.

לאחר מכן, מסמנים את תיבת הסימון שלצד הרשימה ולוחצים על שמירת השינויים.

לאחר מכן, מוסיפים את בודקי הרישיונות:

  1. חוזרים לתצוגה כל האפליקציות ב-Google Play Console.
  2. עוברים אל הגדרות > בדיקת רישיון.
  3. מוסיפים את אותן כתובות אימייל של הבודקים שצריכים לבדוק רכישות מתוך האפליקציה.
  4. מגדירים את License response (תגובה לרישיון) ל-RESPOND_NORMALLY.
  5. לוחצים על שמירת השינויים.

a1a0f9d3e55ea8da.png

הגדרת רכישות מתוך האפליקציה

עכשיו מגדירים את הפריטים שאפשר לקנות באפליקציה.

בדיוק כמו ב-App Store, אתם צריכים להגדיר שלוש רכישות שונות:

  • dash_consumable_2k: רכישה מתכלה שאפשר לרכוש הרבה פעמים, שמעניקה למשתמש 2,000 יחידות Dash (המטבע באפליקציה) לכל רכישה.
  • dash_upgrade_3d: רכישת 'שדרוג' חד-פעמית שלא ניתן לצרוך, שמעניקה למשתמש מקף שונה מבחינה ויזואלית שאפשר ללחוץ עליו.
  • dash_subscription_doubler: מינוי שמעניק למשתמש פי שניים דאשים לכל קליק למשך תקופת המינוי.

קודם כול, מוסיפים את הפריט המתכלה ואת הפריט הלא מתכלה.

  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

ב-codelab הזה תשתמשו בשירות בק-אנד כדי לאמת ולעקוב אחרי הרכישות של המשתמשים.

לשימוש בשירות backend יש כמה יתרונות:

  • אתם יכולים לאמת עסקאות בצורה מאובטחת.
  • אתם יכולים להגיב לאירועי חיוב מחנויות האפליקציות.
  • אפשר לעקוב אחרי הרכישות במסד נתונים.
  • המשתמשים לא יוכלו להערים על האפליקציה כדי לקבל תכונות פרימיום על ידי החזרת השעון של המערכת לאחור.

יש הרבה דרכים להגדיר שירות קצה עורפי, אבל אנחנו נשתמש ב-Cloud Functions וב-Firestore באמצעות Firebase של Google.

כתיבת הקצה העורפי לא נכללת בתחום של ה-codelab הזה, ולכן קוד ההתחלה כבר כולל פרויקט Firebase שמטפל ברכישות בסיסיות כדי שתוכלו להתחיל.

גם פלאגינים של Firebase כלולים באפליקציית המתחילים.

מה שנותר לכם לעשות הוא ליצור פרויקט Firebase משלכם, להגדיר את האפליקציה ואת ה-Backend ל-Firebase, ולבסוף לפרוס את ה-Backend.

יצירת פרויקט Firebase

עוברים אל מסוף Firebase ויוצרים פרויקט חדש ב-Firebase. בדוגמה הזו, שם הפרויקט הוא Dash Clicker.

באפליקציה לקצה העורפי, אתם מקשרים רכישות למשתמש ספציפי, ולכן אתם צריכים אימות. לשם כך, משתמשים במודול האימות של Firebase עם כניסה באמצעות חשבון Google.

  1. במרכז הבקרה של Firebase, עוברים אל אימות ומפעילים אותו, אם צריך.
  2. עוברים לכרטיסייה שיטת כניסה ומפעילים את ספק הכניסה Google.

fe2e0933d6810888.png

תשתמשו גם במסד הנתונים של Firestore ב-Firebase, לכן צריך להפעיל גם אותו.

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 CLI. פועלים לפי ההוראות שמוסברות בדף ההגדרה.

כשמריצים את הפקודה flutterfire configure, בוחרים את הפרויקט שיצרתם בשלב הקודם.

$ 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, עוברים אל Project Overview (סקירת הפרויקט), בוחרים באפשרות Settings (הגדרות) ולוחצים על הכרטיסייה General (כללי).

גוללים למטה אל האפליקציות שלך ובוחרים באפליקציה dashclicker (android).

b22d46a759c0c834.png

כדי לאפשר כניסה באמצעות חשבון Google במצב ניפוי באגים, צריך לספק את טביעת האצבע של הגיבוב SHA-1 של אישור ניפוי הבאגים.

קבלת הגיבוב (hash) של אישור החתימה לניפוי באגים

בשורש של פרויקט אפליקציית 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. או באמצעות סביבת הפיתוח המשולבת (IDE) שתבחרו.

ב-VSCode לוחצים לחיצה ימנית על התיקייה ios/ ואז על open in xcode.

ב-Android Studio, לוחצים לחיצה ימנית על התיקייה ios/, לוחצים על flutter ואז על האפשרות open iOS module in Xcode.

כדי לאפשר כניסה באמצעות חשבון Google ב-iOS, מוסיפים את אפשרות ההגדרה CFBundleURLTypes לקובצי ה-build 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. האזנה לעדכונים על רכישות

בחלק הזה של ה-codelab תכינו את האפליקציה לרכישת המוצרים. התהליך הזה כולל האזנה לעדכונים ולשגיאות ברכישות אחרי שהאפליקציה מופעלת.

האזנה לעדכונים על רכישות

ב-main.dart, מוצאים את הווידג'ט MyHomePage עם Scaffold שמכיל BottomNavigationBar עם שני דפים. בדף הזה נוצרים גם שלושה Provider עבור DashCounter, ‏ DashUpgrades, וDashPurchases. ‫DashCounter עוקב אחרי המספר הנוכחי של המקפים ומגדיל אותו אוטומטית. ‫DashUpgrades מנהל את השדרוגים שאפשר לקנות באמצעות Dash. ה-codelab הזה מתמקד ב-DashPurchases.

כברירת מחדל, האובייקט של ספק מוגדר כשהאובייקט הזה מתבקש בפעם הראשונה. האובייקט הזה מאזין לעדכוני רכישות ישירות כשהאפליקציה מופעלת, לכן צריך להשבית את הטעינה העצלנית באובייקט הזה באמצעות lazy: false:

lib/main.dart

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

צריך גם מופע של InAppPurchaseConnection. עם זאת, כדי שאפשר יהיה לבדוק את האפליקציה, צריך למצוא דרך לדמות את החיבור. כדי לעשות את זה, יוצרים method של מופע שאפשר לבטל בבדיקה, ומוסיפים אותו ל-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!;
  }
}

מעדכנים את הבדיקה באופן הבא:

test/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 שאפשר להוסיף ללוחות הבקרה שרכשתם.

מוסיפים מאפיין של מינוי לסטרימינג, _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 מאותחל בבונה. הפרויקט הזה מוגדר כברירת מחדל כלא ניתן לאכלוס (NNBD), כלומר מאפיינים שלא הוגדרו כניתנים לאכלוס חייבים להכיל ערך שאינו null. התוחם late מאפשר להגדיר את הערך הזה מאוחר יותר.

ב-constructor, מקבלים את הזרם 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. לבצע רכישות

בחלק הזה של ה-codelab, תחליפו את המוצרים הקיימים לניסיון במוצרים אמיתיים שאפשר לקנות. המוצרים האלה נטענים מהחנויות, מוצגים ברשימה ונרכשים כשמקישים על המוצר.

Adapt PurchasableProduct

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() ב-constructor:

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

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

9. הגדרת הקצה העורפי

לפני שמתקדמים למעקב אחרי רכישות ולאימות שלהן, צריך להגדיר קצה עורפי של Dart כדי לתמוך בפעולות האלה.

בקטע הזה, עובדים מהתיקייה dart-backend/ כשורש.

ודאו שהכלים הבאים מותקנים:

סקירה כללית של פרויקט בסיסי

חלקים מסוימים בפרויקט הזה נחשבים מחוץ להיקף של ה-Codelab הזה, ולכן הם כלולים בקוד ההתחלתי. לפני שמתחילים, כדאי לעבור על מה שכבר קיים בקוד ההתחלתי כדי להבין איך כדאי לתכנן את הדברים.

קוד ה-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 או עם מסדי נתונים באופן כללי לא רלוונטי ל-codelab הזה, ולכן קוד ההתחלה מכיל פונקציות ליצירה או לעדכון של רכישות ב-Firestore, וגם את כל המחלקות של הרכישות האלה.

הגדרת גישה ל-Firebase

כדי לגשת ל-Firebase Firestore, צריך מפתח גישה לחשבון שירות. כדי ליצור מפתח פרטי, פותחים את ההגדרות של פרויקט Firebase, עוברים לקטע חשבונות שירות ובוחרים באפשרות יצירת מפתח פרטי חדש.

27590fc77ae94ad4.png

מעתיקים את קובץ ה-JSON שהורדתם לתיקייה assets/ ומשנים את השם שלו ל-service-account-firebase.json.

הגדרת גישה ל-Google Play

כדי לגשת לחנות Play ולאמת רכישות, צריך ליצור חשבון שירות עם ההרשאות האלה ולהוריד את פרטי הכניסה שלו בפורמט JSON.

  1. נכנסים לדף Google Play Android Developer API במסוף Google Cloud. 629f0bd8e6b50be8.png אם ב-Google Play Console מתבקשים ליצור פרויקט או לקשר לפרויקט קיים, צריך לעשות זאת קודם ואז לחזור לדף הזה.
  2. עוברים אל הדף 'חשבונות שירות' ולוחצים על + יצירת חשבון שירות. 8dc97e3b1262328a.png
  3. מזינים את שם חשבון השירות ולוחצים על יצירה והמשך. 4fe8106af85ce75f.png
  4. בוחרים בתפקיד Pub/Sub Subscriber ולוחצים על Done. a5b6fa6ea8ee22d.png
  5. אחרי שיוצרים את החשבון, עוברים אל ניהול מפתחות. eb36da2c1ad6dd06.png
  6. בוחרים באפשרות Add key > Create new key (הוספת מפתח > יצירת מפתח חדש). e92db9557a28a479.png
  7. יוצרים ומורידים מפתח JSON. 711d04f2f4176333.png
  8. משנים את השם של הקובץ שהורדתם ל-service-account-google-play.json, ומעבירים אותו לספרייה assets/.
  9. לאחר מכן, עוברים לדף משתמשים והרשאות ב-Play Console28fffbfc35b45f97.png
  10. לוחצים על הזמנת משתמשים חדשים ומזינים את כתובת האימייל של חשבון השירות שנוצר קודם. אפשר למצוא את כתובת האימייל בטבלה בדף Service accountse3310cc077f397d.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.

בשתי החנויות, האפליקציה מקבלת אסימון כשמתבצעת רכישה.

האפליקציה שולחת את האסימון הזה לשירות הבק-אנד שלכם, שמאמת את הרכישה מול השרתים של החנות הרלוונטית באמצעות האסימון שסופק.

לאחר מכן, שירות ה-Backend יכול לבחור אם לאחסן את הרכישה, ולהשיב לאפליקציה אם הרכישה תקפה או לא.

אם שירות ה-backend מבצע את האימות מול החנויות במקום האפליקציה שפועלת במכשיר של המשתמש, אפשר למנוע מהמשתמש לקבל גישה לתכונות פרימיום, למשל על ידי החזרת השעון של המערכת אחורה.

הגדרת הצד של Flutter

הגדרת אימות

אתם מתכוונים לשלוח את הרכישות לשירות הקצה העורפי, ולכן אתם רוצים לוודא שהמשתמש מאומת בזמן הרכישה. רוב הלוגיקה של האימות כבר נוספה בשבילכם בפרויקט המתחיל, וכל מה שאתם צריכים לעשות זה לוודא שPurchasePage מציג את לחצן הכניסה כשהמשתמש עדיין לא מחובר. מוסיפים את הקוד הבא לתחילת שיטת ה-build של 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 post.

שולחים את החנות שנבחרה (google_play לחנות Play או 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:

lib/main.dart

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

מוסיפים פונקציית getter למשתמש ב-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);
    }
  }

באפליקציה, הכול מוכן עכשיו לאימות הרכישות.

הגדרת שירות הקצה העורפי

לאחר מכן, מגדירים את ה-backend לאימות רכישות ב-backend.

יצירת רכיבי handler של רכישות

תהליך האימות בשתי החנויות כמעט זהה, ולכן כדאי להגדיר מחלקה מופשטת 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: הטוקן שסופק למשתמש על ידי החנות.

בנוסף, כדי להקל על השימוש ב-handlers האלה של רכישות, מוסיפים שיטה 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 מכילות מידע בסיסי על המוצרים השונים שניתן לרכוש, כולל מזהה המוצר (שנקרא לפעמים גם מק"ט) ו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,
  ),
};

לאחר מכן, מגדירים כמה הטמעות של placeholder לחנות 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. ה-handler של הרכישה משתמש במופע הזה כדי לאחסן מידע על רכישות ב-Firestore בהמשך. כדי לתקשר עם Google Play, משתמשים בAndroidPublisherApi שסופק.

לאחר מכן, מבצעים את אותה פעולה עבור רכיב ה-handler של חנות האפליקציות. יוצרים 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;
  }
}

מצוין! עכשיו יש לכם שני רכיבי handler של רכישות. לאחר מכן, יוצרים את נקודת הקצה של ה-API לאימות הרכישה.

שימוש ב-Purchase Handlers

פותחים את bin/server.dart ויוצרים נקודת קצה (endpoint) של API באמצעות shelf_route:

bin/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, צריך להגדיר את שני רכיבי ה-handler של הרכישה. לשם כך, צריך לטעון את המפתחות של חשבון השירות שהתקבלו בשלב הקודם ולהגדיר את הגישה לשירותים השונים, כולל Android Publisher API ו-Firebase Firestore API. לאחר מכן, יוצרים את שני רכיבי ה-handler של הרכישה עם התלויות השונות:

bin/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: הטמעה של רכיב לטיפול ברכישות

לאחר מכן, ממשיכים להטמיע את רכיב ה-handler של הרכישות ב-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;
  }

אפשר לעדכן את רכיב ה-handler של רכישת המינוי באופן דומה:

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

עכשיו, בניגוד ל-Google Play APIs, ב-App Store נעשה שימוש באותם נקודות קצה של API גם למינויים וגם למוצרים שאינם מינויים. המשמעות היא שאפשר להשתמש באותה לוגיקה בשני ה-handlers. ממזגים אותם כך שהם קוראים לאותו יישום:

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 אמורות להיות מאומתות עכשיו ומאוחסנות במסד הנתונים.

הפעלת ה-backend

בשלב הזה אתם יכולים להריץ את dart bin/server.dart כדי להציג את נקודת הקצה /verifypurchase.

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

11. מעקב אחר רכישות

הדרך המומלצת לעקוב אחרי הרכישות של המשתמשים היא בשירות העורפי. הסיבה לכך היא שהקצה העורפי יכול להגיב לאירועים מהחנות, ולכן הוא פחות מועד לשימוש במידע לא מעודכן בגלל שמירה במטמון, וגם פחות רגיש לשיבוש.

קודם כל, מגדירים את העיבוד של אירועים בחנות בקצה העורפי באמצעות קצה העורפי של Dart שאתם בונים.

עיבוד אירועים בחנות בקצה העורפי

חנויות יכולות להודיע לשרת העורפי על אירועי חיוב שמתרחשים, למשל כשמינויים מתחדשים. אתם יכולים לעבד את האירועים האלה בקצה העורפי כדי שהרכישות במסד הנתונים שלכם יהיו עדכניות. בקטע הזה, מגדירים את האפשרות הזו גם לחנות Google Play וגם ל-App Store של Apple.

עיבוד אירועי חיוב ב-Google Play

‫Google Play מספקת אירועי חיוב דרך מה שהם מכנים נושא Pub/Sub ב-Cloud. אלה בעצם תורים של הודעות שאפשר לפרסם בהם הודעות וגם לקרוא מהם הודעות.

מכיוון שהפונקציונליות הזו ספציפית ל-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 להגדרות פרטי הכניסה:

bin/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:

bin/server.dart

  final pubsubApi = pubsub.PubsubApi(clientGooglePlay);

ולבסוף, מעבירים אותו לבונה GooglePlayPurchaseHandler:

bin/server.dart

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

הגדרת Google Play

כתבתם את הקוד לצריכת אירועי חיוב מנושא ה-Pub/Sub, אבל לא יצרתם את נושא ה-Pub/Sub ולא פרסמתם אירועי חיוב. הגיע הזמן להגדיר את זה.

קודם כל, יוצרים נושא Pub/Sub:

  1. מגדירים את הערך של googleCloudProjectId ב-constants.dart למזהה של הפרויקט ב-Google Cloud.
  2. נכנסים לדף Cloud Pub/Sub במסוף Google Cloud.
  3. מוודאים שאתם נמצאים בפרויקט Firebase ולוחצים על + יצירת נושא. d5ebf6897a0a8bf5.png
  4. נותנים לנושא החדש שם זהה לערך שהוגדר ל-googlePlayPubsubBillingTopic ב-constants.dart. במקרה הזה, נותנים את השם play_billing. אם בוחרים משהו אחר, צריך לעדכן את constants.dart. יוצרים את הנושא. 20d690fc543c4212.png
  5. ברשימת הנושאים שלכם ב-Pub/Sub, לוחצים על סמל שלוש הנקודות האנכיות של הנושא שיצרתם ולוחצים על View permissions (הצגת הרשאות). ea03308190609fb.png
  6. בסרגל הצד משמאל, לוחצים על הוספת ישות ראשית.
  7. מוסיפים כאן את google-play-developer-notifications@system.gserviceaccount.com ומעניקים לו את התפקיד פרסום הודעות ב-Pub/Sub. 55631ec0549215bc.png
  8. שומרים את השינויים בהרשאות.
  9. מעתיקים את שם הנושא שיצרתם.
  10. פותחים שוב את Play Console ובוחרים את האפליקציה מהרשימה כל האפליקציות.
  11. גוללים למטה אל מונטיזציה > הגדרת מונטיזציה.
  12. ממלאים את הנושא המלא ושומרים את השינויים. 7e5e875dc6ce5d54.png

כל האירועים שקשורים לחיוב ב-Google Play יפורסמו עכשיו בנושא.

עיבוד אירועי חיוב בחנות האפליקציות

לאחר מכן, מבצעים את אותה פעולה לגבי אירועי חיוב ב-App Store. יש שתי דרכים יעילות להטמיע עדכונים בטיפול ברכישות ב-App Store. אחת מהן היא הטמעה של webhook שאתם מספקים ל-Apple, והיא משתמשת בו כדי לתקשר עם השרת שלכם. הדרך השנייה, שמוסברת ב-codelab הזה, היא להתחבר ל-App Store Server API ולקבל את פרטי המינוי באופן ידני.

הסיבה לכך ש-codelab הזה מתמקד בפתרון השני היא שצריך לחשוף את השרת לאינטרנט כדי להטמיע את ה-webhook.

בסביבת ייצור, מומלץ שיהיו לכם את שניהם. ‫Webhook כדי לקבל אירועים מ-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
  );

משנים את ה-constructor כדי להוסיף טיימר שיבצע קריאה ל-method‏ _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. לכל הזמנה, המערכת שולחת בקשה לסטטוס המינוי אל App Store Server API.
  3. מקבל את העסקה האחרונה עבור רכישת המינוי.
  4. בודק את תאריך התפוגה.
  5. הסטטוס של המינוי מתעדכן ב-Firestore, ואם תוקף המינוי פג הוא יסומן ככזה.

לבסוף, מוסיפים את כל הקוד הנדרש כדי להגדיר את הגישה ל-API של App Store Server:

bin/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

לאחר מכן מגדירים את App Store:

  1. מתחברים ל-App Store Connect ובוחרים באפשרות Users and Access (משתמשים וגישה).
  2. עוברים אל Integrations > Keys > In-App Purchase (שילובים > מפתחות > רכישה מתוך האפליקציה).
  3. מקישים על סמל הפלוס כדי להוסיף עוד אחד.
  4. נותנים לו שם, כמו Codelab key.
  5. מורידים את קובץ ה-p8 שמכיל את המפתח.
  6. מעתיקים אותו לתיקיית הנכסים, עם השם SubscriptionKey.p8.
  7. מעתיקים את מזהה המפתח מהמפתח החדש שנוצר ומגדירים אותו כקבוע appStoreKeyId בקובץ lib/constants.dart.
  8. מעתיקים את מזהה הגורם המנפיק שמופיע בראש רשימת המפתחות, ומגדירים אותו כקבוע appStoreIssuerId בקובץ lib/constants.dart.

9540ea9ada3da151.png

מעקב אחרי רכישות במכשיר

הדרך הכי מאובטחת לעקוב אחרי הרכישות היא בצד השרת, כי קשה לאבטח את הלקוח. עם זאת, צריך למצוא דרך להעביר את המידע בחזרה ללקוח כדי שהאפליקציה תוכל לפעול בהתאם למידע על סטטוס המינוי. אחסון הרכישות ב-Firestore מאפשר לסנכרן את הנתונים עם הלקוח ולעדכן אותם באופן אוטומטי.

כבר הוספתם את IAPRepo לאפליקציה. זהו מאגר Firestore שמכיל את כל נתוני הרכישות של המשתמש ב-List<PastPurchase> purchases. המאגר מכיל גם את hasActiveSubscription,, שהוא true אם יש רכישה עם 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(). בהתחלה, ה-listener יכול להיות פונקציה ריקה. מכיוון ש-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 ל-constructor ב-main.dart. אפשר לקבל את המאגר באמצעות context.read כי הוא כבר נוצר ב-Provider.

lib/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, המכפיל מוגדר ל-10 או ל-1, בהתאמה, כך שלא צריך לבדוק אם המינוי כבר הוחל.removePaidMultiplier כשסטטוס המינוי משתנה, צריך לעדכן גם את הסטטוס של המוצר שאפשר לרכוש, כדי שבדף הרכישה יוצג שהמוצר כבר פעיל. מגדירים את המאפיין _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.png complete.

כדי לקבל מידע נוסף, אפשר לנסות את הסדנאות האחרות ללימוד Flutter.