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

1. מבוא

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

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

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

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

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

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

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

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

מה תפַתחו

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

מה תלמדו

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

מה נדרש

  • Android Studio 4.1 ואילך
  • Xcode מגרסה 12 ואילך (לפיתוח ל-iOS)
  • Flutter SDK

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

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

מורידים את הקוד

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

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

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

gh repo clone flutter/codelabs flutter-codelabs

הקוד לדוגמה משובט לספרייה flutter-codelabs שמכילה את הקוד של אוסף של Codelabs. הקוד של Codelab הזה נמצא ב-flutter-codelabs/in_app_purchases.

מבנה הספרייה ב-flutter-codelabs/in_app_purchases מכיל סדרה של קובצי snapshot של המצב שבו אתם אמורים להיות בסוף כל שלב בעל שם. קוד ההתחלה נמצא בשלב 0, כך שקל למצוא את הקבצים התואמים:

cd flutter-codelabs/in_app_purchases/step_00

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

הגדרת הפרויקט למתחילים

פותחים את פרויקט ההתחלה מ-step_00 בסביבת הפיתוח המשולבת (IDE) המועדפת עליכם. השתמשנו ב-Android Studio לצילום המסכים, אבל גם Visual Studio Code הוא פתרון מצוין. בכל אחד מהעורכים, מוודאים שמותקנים הפלאגינים העדכניים ביותר של Dart ו-Flutter.

האפליקציות שתיצרו צריכות לתקשר עם App Store ו-Play Store כדי לדעת אילו מוצרים זמינים ובאיזה מחיר. לכל אפליקציה יש מזהה ייחודי. ב-App Store ל-iOS, המזהה הזה נקרא מזהה החבילה, וב-Google Play ל-Android הוא נקרא מזהה האפליקציה. בדרך כלל, מזהי ה-ID האלה נוצרים באמצעות סימון הפוך של שם הדומיין. לדוגמה, כשאתם יוצרים אפליקציה עם רכישות מתוך האפליקציה עבור flutter.dev, אתם משתמשים ב-dev.flutter.inapppurchase. צריך לחשוב על מזהה לאפליקציה. עכשיו מגדירים אותו בהגדרות הפרויקט.

קודם כול, מגדירים את מזהה החבילה ל-iOS.

כשהפרויקט פתוח ב-Android Studio, לוחצים לחיצה ימנית על התיקייה iOS, לוחצים על Flutter ופותחים את המודול באפליקציית Xcode.

942772eb9a73bfaa.png

במבנה התיקיות של Xcode, פרויקט Runner נמצא בחלק העליון, והיעדים Flutter,‏ Runner ו-Products נמצאים מתחת לפרויקט Runner. לוחצים לחיצה כפולה על Runner כדי לערוך את הגדרות הפרויקט, ואז לוחצים על Signing & Capabilities. מזינים את מזהה החבילה שבחרתם בשדה צוות כדי להגדיר את הצוות.

812f919d965c649a.jpeg

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

5c4733ac560ae8c2.png

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

בקטע הזה של סדנת הקוד, תתקינו את הפלאגין in_app_purchase.

הוספת תלות ב-pubspec

מוסיפים את in_app_purchase ל-pubspec על ידי הוספת in_app_purchase ליחסי התלות ב-pubspec:

$ cd app
$ flutter pub add in_app_purchase dev:in_app_purchase_platform_interface

פותחים את pubspec.yaml ומוודאים ש-in_app_purchase מופיע כרשומה בקטע dependencies ו-in_app_purchase_platform_interface בקטע dev_dependencies.

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  cloud_firestore: ^5.5.1
  cupertino_icons: ^1.0.8
  firebase_auth: ^5.3.4
  firebase_core: ^3.8.1
  google_sign_in: ^6.2.2
  http: ^1.2.2
  intl: ^0.20.1
  provider: ^6.1.2
  in_app_purchase: ^3.2.0

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

לוחצים על pub get כדי להוריד את החבילה או מריצים את flutter pub get בשורת הפקודה.

4. הגדרת App Store

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

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

11db9fca823e7608.png

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

74c73197472c9aec.png

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

4a100bbb8cafdbbf.jpeg

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

יוצרים מזהה חדש בפורטל למפתחים של Apple.

55d7e592d9a3fc7b.png

בחירת מזהי אפליקציות

13f125598b72ca77.png

בחירת אפליקציה

41ac4c13404e2526.png

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

9d2c940ad80deeef.png

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

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

יוצרים אפליקציה חדשה ב-App Store Connect עם מזהה החבילה הייחודי.

10509b17fbf031bd.png

5b7c0bb684ef52c7.png

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

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

3ca2b26d4e391a4c.jpeg

עכשיו אפשר להגדיר את המשתמש ב-sandbox ב-iPhone. לשם כך, עוברים אל הגדרות > App Store > Sandbox-account.

d99e0b89673867cd.jpeg e1621bcaeb33d3c5.jpeg

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

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

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

d156b2f5bac43ca8.png

עוברים אל רכישות מתוך האפליקציות > ניהול.

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

  1. מגדירים את dash_consumable_2k כפריט לשימוש.

משתמשים ב-dash_consumable_2k בתור מזהה המוצר. שם העזר משמש רק ב-App Store Connect. פשוט מגדירים אותו כ-dash consumable 2k ומוסיפים את הגרסאות המקומיות של הרכישה. קוראים לרכישה Spring is in the air עם 2000 dashes fly out כתיאור.

ec1701834fd8527.png

  1. מגדירים את dash_upgrade_3d כלא מתכלה.

משתמשים ב-dash_upgrade_3d בתור מזהה המוצר. מגדירים את שם העזרה כ-dash upgrade 3d ומוסיפים את הגרסאות המקומיות של הרכישה. קוראים לרכישה 3D Dash עם Brings your dash back to the future כתיאור.

6765d4b711764c30.png

  1. מגדירים את dash_subscription_doubler כמינוי מתחדש אוטומטית.

התהליך של המינויים שונה במקצת. קודם צריך להגדיר את שם ההפניה ואת מזהה המוצר:

6d29e08dae26a0c4.png

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

5bd0da17a85ac076.png

בשלב הבא, מזינים את משך המינוי ואת הגרסאות המקומיות. נותנים שם Jet Engine ותיאור Doubles your clicks למינוי. לוחצים על שמירה.

bd1b1d82eeee4cb3.png

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

d0bf39680ef0aa2e.png

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

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 גרסה אחת לפחות של build.

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

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

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

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

מוסיפים את פרטי מאגר המפתחות מקובץ המאפיינים לפני הבלוק 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
       }
   }

מגדירים את הבלוק signingConfigs בקובץ build.gradle של המודול עם פרטי תצורת החתימה:

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

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

מידע נוסף על חתימה על האפליקציה זמין במאמר חתימה על האפליקציה בכתובת developer.android.com.

העלאת הגרסה הראשונה

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

flutter build appbundle

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

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

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

ba98446d9c5c40e0.png

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

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

לסיום, לוחצים על Start rollout to Internal testing (התחלת ההשקה לבדיקות פנימיות) כדי להפעיל את הגרסה לבדיקות פנימיות.

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

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

  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 Dashes (המטבע באפליקציה) לכל רכישה.
  • dash_upgrade_3d: רכישה של 'שדרוג' שאינו מתכלה, שאפשר לרכוש רק פעם אחת. הרכישה הזו מעניקה למשתמש לחצן Dash שונה מבחינה קוסמטית.
  • dash_subscription_doubler: מינוי שמעניק למשתמש פי שניים יותר קווים דקים לכל קליק למשך תקופת המינוי.

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

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

בשלב הבא, מוסיפים את המינוי:

  1. נכנסים ל-Google Play Console ובוחרים את האפליקציה.
  2. עוברים אל מונטיזציה > מוצרים > מינויים.
  3. לוחצים על Create subscription (יצירת מינוי)32a6a9eefdb71dd0.png
  4. מזינים את כל הפרטים הנדרשים במינוי. חשוב לוודא שמזהה המוצר תואם בדיוק למזהה שבו אתם מתכוונים להשתמש.
  5. לוחצים על שמירה.

עכשיו רכישות ה-IAP אמורות להיות מוגדרות ב-Play Console.

6. הגדרת Firebase

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

לשימוש בשירות לקצה העורפי יש כמה יתרונות:

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

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

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

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

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

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

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

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

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

7babb48832fbef29.png

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

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

הגדרת Firebase ל-Flutter

הדרך המומלצת להתקין את Firebase באפליקציית Flutter היא להשתמש ב-CLI של FlutterFire. פועלים לפי ההוראות שמפורטות בדף ההגדרה.

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

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

b22d46a759c0c834.png

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

אחזור הגיבוב של אישור החתימה לצורך ניפוי באגים

בתיקיית השורש של פרויקט אפליקציית Flutter, עוברים לתיקייה android/ ויוצרים דוח חתימה.

cd android
./gradlew :app:signingReport

תוצג לכם רשימה גדולה של מפתחות חתימה. מכיוון שאתם מחפשים את הגיבוב של אישור ניפוי הבאגים, מחפשים את האישור שבו המאפיינים Variant ו-Config מוגדרים כ-debug. סביר להניח שמאגר המפתחות נמצא בתיקיית הבית שלכם, בקטע .android/debug.keystore.

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

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

הגדרת Firebase ל-iOS: שלבים נוספים

פותחים את ios/Runnder.xcworkspace באמצעות Xcode. או באמצעות סביבת הפיתוח המשולבת (IDE) המועדפת עליכם.

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

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

כדי לאפשר כניסה באמצעות חשבון Google ב-iOS, מוסיפים את אפשרות התצורה CFBundleURLTypes לקובצי ה-plist של ה-build. (מידע נוסף זמין במסמכי העזרה של חבילת google_sign_in). במקרה הזה, הקבצים הם ios/Runner/Info-Debug.plist ו-ios/Runner/Info-Release.plist.

צמד המפתח/ערך כבר נוסף, אבל צריך להחליף את הערכים שלו:

  1. אחזור הערך של REVERSED_CLIENT_ID מהקובץ GoogleService-Info.plist, ללא הרכיב <string>..</string> שמקיף אותו.
  2. מחליפים את הערך בקובץ ios/Runner/Info-Debug.plist ובקובץ ios/Runner/Info-Release.plist, מתחת למפתח CFBundleURLTypes.
<key>CFBundleURLTypes</key>
<array>
    <dict>
        <key>CFBundleTypeRole</key>
        <string>Editor</string>
        <key>CFBundleURLSchemes</key>
        <array>
            <!-- TODO Replace this value: -->
            <!-- Copied from GoogleService-Info.plist key REVERSED_CLIENT_ID -->
            <string>com.googleusercontent.apps.REDACTED</string>
        </array>
    </dict>
</array>

סיימתם את ההגדרה של Firebase.

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

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

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

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

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

lib/main.dart

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

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

lib/main.dart

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

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

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

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, עוברים לקוד של DashPurchases ChangeNotifier. נכון לעכשיו, יש רק DashCounter שאפשר להוסיף ל-Dashes שנרכשו.

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

ב-constructor, מקבלים את הסטרימינג purchaseUpdated ומתחילים להאזין לסטרימינג. בשיטה dispose(), מבטלים את המינוי לשידור.

lib/logic/dash_purchases.dart

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

  bool get beautifiedDash => false;

  final iapConnection = IAPConnection.instance;

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

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

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

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

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

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

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

$ flutter test

00:01 +1: All tests passed!                                                                                   

8. לבצע רכישות

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

התאמה של PurchasableProduct

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

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

לבסוף, משנים את הערך של השדה 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 ומעדכנים את פונקציית ה-getter של beautifiedDash כך שתצביע עליו.

lib/logic/dash_purchases.dart

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

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

lib/logic/dash_purchases.dart

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

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

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

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

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

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

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

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

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

קוד הקצה העורפי הזה יכול לפעול באופן מקומי במחשב, ואין צורך לפרוס אותו כדי להשתמש בו. עם זאת, צריך להיות אפשרות להתחבר ממכשיר הפיתוח (Android או iPhone) למכונה שבה השרת יפעל. לשם כך, הם צריכים להיות באותה רשת, וצריך לדעת את כתובת ה-IP של המכונה.

מנסים להריץ את השרת באמצעות הפקודה הבאה:

$ dart ./bin/server.dart

Serving at http://0.0.0.0:8080

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

חלק אחד שכבר כלול בקוד ההתחלה הוא IapRepository ב-lib/iap_repository.dart. מאחר שלמידת האינטראקציה עם Firestore או עם מסדי נתונים באופן כללי לא רלוונטית לסדנת הקוד הזו, קוד ההתחלה מכיל פונקציות ליצירה או לעדכון של רכישות ב-Firestore, וגם את כל הכיתות של הרכישות האלה.

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

כדי לגשת ל-Firebase Firestore, צריך מפתח גישה לחשבון שירות. כדי ליצור מפתח כזה, פותחים את ההגדרות של פרויקט Firebase ועוברים לקטע Service accounts (חשבונות שירות), ואז בוחרים באפשרות Generate new private key (יצירת מפתח פרטי חדש).

27590fc77ae94ad4.png

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

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

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

  1. נכנסים ל-Google Play Console ומתחילים מהדף All apps (כל האפליקציות).
  2. עוברים אל הגדרה > גישה ל-API. 317fdfb54921f50e.png אם ב-Google Play Console תופיע בקשה ליצור פרויקט או לקשר פרויקט קיים, עליכם לבצע את הפעולה הזו קודם ואז לחזור לדף הזה.
  3. מאתרים את הקטע שבו אפשר להגדיר חשבונות שירות ולוחצים על Create new service account.1e70d3f8d794bebb.png
  4. לוחצים על הקישור Google Cloud Platform בתיבת הדו-שיח הקופצת. 7c9536336dd9e9b4.png
  5. בוחרים את הפרויקט הרצוי. אם היא לא מופיעה, מוודאים שנכנסתם לחשבון Google הנכון ברשימה הנפתחת Account (חשבון) בפינה הימנית העליונה. 3fb3a25bad803063.png
  6. אחרי שבוחרים את הפרויקט, לוחצים על + יצירת חשבון שירות בסרגל התפריטים העליון. 62fe4c3f8644acd8.png
  7. נותנים שם לחשבון השירות, אפשר גם להוסיף תיאור כדי לזכור למה הוא מיועד, וממשיכים לשלב הבא. 8a92d5d6a3dff48c.png
  8. מקצים לחשבון השירות את התפקיד עריכה. 6052b7753667ed1a.png
  9. מסיימים את האשף, חוזרים לדף API Access במסוף הפיתוח ולוחצים על Refresh service accounts. החשבון החדש שיצרתם אמור להופיע ברשימה. 5895a7db8b4c7659.png
  10. לוחצים על Grant access (מתן גישה) לחשבון השירות החדש.
  11. גוללים למטה בדף הבא, אל הבלוק נתונים פיננסיים. בוחרים גם באפשרות הצגת נתונים פיננסיים, הזמנות ותשובות לסקר הביטול וגם באפשרות ניהול הזמנות ומינויים. 75b22d0201cf67e.png
  12. לוחצים על הזמנת משתמש. 70ea0b1288c62a59.png
  13. עכשיו, אחרי שהחשבון מוגדר, צריך ליצור פרטי כניסה. חוזרים למסוף Cloud, מחפשים את חשבון השירות ברשימה של חשבונות השירות, לוחצים על שלוש הנקודות האנכיות ובוחרים באפשרות Manage keys. 853ee186b0e9954e.png
  14. יוצרים מפתח JSON חדש ומורידים אותו. 2a33a55803f5299c.png cb4bf48ebac0364e.png
  15. משנים את השם של הקובץ שהורדתם ל-service-account-google-play.json, ומעבירים אותו לספרייה assets/.

דבר נוסף שצריך לעשות הוא לפתוח את lib/constants.dart, ולהחליף את הערך של androidPackageId במזהה החבילה שבחרתם לאפליקציה ל-Android.

הגדרת גישה ל-Apple 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. לדוגמה: com.example.dashclicker
  • appStoreSharedSecret: סוד משותף לגישה ל-App Store Connect כדי לבצע אימות רכישות.
  • bundleId: מזהה החבילה שמשמש ב-iOS. לדוגמה: com.example.dashclicker

בשלב הזה אפשר להתעלם משאר הקבועים.

10. אימות רכישות

התהליך הכללי לאימות רכישות דומה ב-iOS וב-Android.

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

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

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

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

הגדרת הצד של Flutter

הגדרת אימות

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

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

באפליקציה, יוצרים את הפונקציה _verifyPurchase(PurchaseDetails purchaseDetails) שמפעילה את נקודת הקצה /verifypurchase בקצה העורפי של Dart באמצעות קריאה ל-http post.

שולחים את החנות שנבחרה (google_play לחנות Play או app_store ל-App Store), את serverVerificationData ואת productID. השרת מחזיר קוד סטטוס שמציין אם הרכישה אומתה.

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

lib/logic/dash_purchases.dart

  FirebaseNotifier firebaseNotifier;

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

הוספת 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

  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) {
      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(1000);
        }
      }
    }

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

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

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

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

יצירת פונקציות לטיפול ברכישות

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

be50c207c5a2a519.png

מתחילים בהוספת קובץ purchase_handler.dart לתיקייה lib/, שבו מגדירים את הכיתה המצומצמת PurchaseHandler עם שתי שיטות מצומצממות לאימות של שני סוגים שונים של רכישות: מינויים ולא מינויים.

lib/purchase_handler.dart

import 'products.dart';

/// Generic purchase handler,
/// must be implemented for Google Play and Apple Store
abstract class PurchaseHandler {

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

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

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

  • userId: המזהה של המשתמש שמחובר, כדי שתוכלו לשייך רכישות למשתמש.
  • productData: נתונים על המוצר. נגדיר את זה עוד רגע.
  • token: האסימון שסופק למשתמש על ידי החנות.

בנוסף, כדי להקל על השימוש בטיפולים האלה ברכישות, מוסיפים את השיטה verifyPurchase() שאפשר להשתמש בה גם למינויים וגם לרכישות אחרות:

lib/purchase_handler.dart

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

עכשיו אפשר פשוט להפעיל את verifyPurchase בשני המקרים, אבל עדיין להשתמש בהטמעות נפרדות!

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

כפי שראיתם, ה-constructor מקבל מופע של 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 ויוצרים נקודת קצה של API באמצעות shelf_route:

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.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. מפענחים את המטען הייעודי (payload) של ה-JSON ומחליצים את המידע הבא:
  3. userId: מזהה המשתמש שמחובר עכשיו
  4. source: החנות שבה נעשה שימוש, app_store או google_play.
  5. productData: מתקבל מה-productDataMap שיצרתם קודם.
  6. token: מכיל את נתוני האימות שצריך לשלוח לחנויות.
  7. קריאה ל-method‏ verifyPurchase, עבור GooglePlayPurchaseHandler או AppStorePurchaseHandler, בהתאם למקור.
  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 כבר מספקת חבילות Dart ליצירת אינטראקציה עם ממשקי ה-API הנדרשים לאימות רכישות. ביצעתם את האיניציאליזציה שלהם בקובץ 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;
  }
}

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

lib/google_play_purchase_handler.dart

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

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

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

עכשיו הרכישות ב-Google Play אמורות להיות מאומתות ונשמרות במסד הנתונים.

בשלב הבא, עוברים לרכישות מ-App Store ל-iOS.

אימות רכישות ב-iOS: הטמעת הטיפול ברכישות

כדי לאמת רכישות דרך App Store, יש חבילה של Dart של צד שלישי בשם app_store_server_sdk שמקלה על התהליך.

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

lib/app_store_purchase_handler.dart

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

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

lib/app_store_purchase_handler.dart

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

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

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

עכשיו מטמיעים את 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) {
      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 וגם ל-App Store של Apple.

עיבוד אירועי חיוב ב-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;

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

הקוד שהוספתם מתקשר עם נושא Pub/Sub מ-Google Cloud כל עשר שניות ומבקש הודעות חדשות. לאחר מכן, מעבדים כל הודעה בשיטה _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. נכנסים לדף Cloud Pub/Sub במסוף Google Cloud.
  2. מוודאים שנמצאים בפרויקט Firebase ולוחצים על + יצירת נושא. d5ebf6897a0a8bf5.png
  3. נותנים שם לנושא החדש, זהה לערך שהוגדר ל-GOOGLE_PLAY_PUBSUB_BILLING_TOPIC בקובץ constants.ts. במקרה הזה, נותנים את השם play_billing. אם בוחרים משהו אחר, חשוב לעדכן את constants.ts. יוצרים את הנושא. 20d690fc543c4212.png
  4. ברשימת הנושאים ב-Pub/Sub, לוחצים על שלוש הנקודות האנכיות של הנושא שיצרתם זה עתה, ואז לוחצים על View permissions (הצגת ההרשאות). ea03308190609fb.png
  5. בסרגל הצד שמימין, בוחרים באפשרות Add principal.
  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. אחת מהן היא הטמעת webhook שסיפקתם ל-Apple, והיא משתמשת בו כדי לתקשר עם השרת שלכם. הדרך השנייה, שאותה תלמדו ב-codelab הזה, היא להתחבר ל-App Store Server API ולקבל את פרטי המינוי באופן ידני.

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

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

  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. אם המינוי פג תוקף, הוא יסומן ככזה.

לבסוף, מוסיפים את כל הקוד הנדרש כדי להגדיר את הגישה ל-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 ובוחרים באפשרות Users and Access (משתמשים וגישה).
  2. עוברים אל סוג מפתח > רכישה מתוך האפליקציה.
  3. מקישים על סמל הפלוס כדי להוסיף חשבון חדש.
  4. נותנים לו שם, למשל 'מפתח Codelab'.
  5. מורידים את קובץ ה-P8 שמכיל את המפתח.
  6. מעתיקים אותו לתיקיית הנכסים עם השם SubscriptionKey.p8.
  7. מעתיקים את מזהה המפתח מהמפתח החדש שנוצר ומגדירים אותו כקבוע appStoreKeyId בקובץ lib/constants.dart.
  8. מעתיקים את מזהה המנפיק ממש בחלק העליון של רשימת המפתחות, ומגדירים אותו כקבוע appStoreIssuerId בקובץ lib/constants.dart.

9540ea9ada3da151.png

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

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

כבר הוספתם את IAPRepo לאפליקציה. זהו המאגר ב-Firestore שמכיל את כל נתוני הרכישות של המשתמש ב-List<PastPurchase> purchases. המאגר מכיל גם את הערך hasActiveSubscription,, שהוא נכון כשיש רכישה עם productId storeKeySubscription שהסטטוס שלה לא פג. כשהמשתמש לא מחובר לחשבון, הרשימה ריקה.

lib/repo/iap_repo.dart

  void updatePurchases() {
    _purchaseSubscription?.cancel();
    var user = _user;
    if (user == null) {
      purchases = [];
      hasActiveSubscription = false;
      hasUpgrade = false;
      return;
    }
    var purchaseStream = _firestore
        .collection('purchases')
        .where('userId', isEqualTo: user.uid)
        .snapshots();
    _purchaseSubscription = purchaseStream.listen((snapshot) {
      purchases = snapshot.docs.map((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 ב-constructor. לאחר מכן, מוסיפים מאזין ישירות ב-constructor ומסירים את המאזין בשיטה dispose(). בשלב הראשון, המאזין יכול להיות פשוט פונקציה ריקה. מכיוון ש-IAPRepo הוא ChangeNotifier, וקוראים ל-notifyListeners() בכל פעם שהרכישות ב-Firestore משתנות, השיטה 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() {
    _subscription.cancel();
    iapRepo.removeListener(purchasesUpdate);
    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>(),
          ),
          lazy: false,
        ),

בשלב הבא, כותבים את הקוד של הפונקציה purchaseUpdate(). ב-dash_counter.dart,, השיטות applyPaidMultiplier ו-removePaidMultiplier מגדירות את המכפיל ל-10 או ל-1, בהתאמה, כך שאין צורך לבדוק אם המינוי כבר הופעל. כשהסטטוס של המינוי משתנה, צריך לעדכן גם את הסטטוס של המוצר שניתן לרכוש, כדי שבדף הרכישה יופיע שהוא כבר פעיל. מגדירים את המאפיין _beautifiedDashUpgrade על סמך הרכישה של השדרוג.

lib/logic/dash_purchases.dart

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

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

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

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

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

12. הכול מוכן!

חדשות טובות! סיימתם את הקודלהב. הקוד המלא של סדנת הקוד הזו נמצא בתיקייה android_studio_folder.pngcomplete.

מידע נוסף זמין בcodelabs האחרים של Flutter.