1 /*
2   ==============================================================================
3 
4    This file is part of the JUCE examples.
5    Copyright (c) 2020 - Raw Material Software Limited
6 
7    The code included in this file is provided under the terms of the ISC license
8    http://www.isc.org/downloads/software-support-policy/isc-license. Permission
9    To use, copy, modify, and/or distribute this software for any purpose with or
10    without fee is hereby granted provided that the above copyright notice and
11    this permission notice appear in all copies.
12 
13    THE SOFTWARE IS PROVIDED "AS IS" WITHOUT ANY WARRANTY, AND ALL WARRANTIES,
14    WHETHER EXPRESSED OR IMPLIED, INCLUDING MERCHANTABILITY AND FITNESS FOR
15    PURPOSE, ARE DISCLAIMED.
16 
17   ==============================================================================
18 */
19 
20 /*******************************************************************************
21  The block below describes the properties of this PIP. A PIP is a short snippet
22  of code that can be read by the Projucer and used to generate a JUCE project.
23 
24  BEGIN_JUCE_PIP_METADATA
25 
26  name:             InAppPurchasesDemo
27  version:          1.0.0
28  vendor:           JUCE
29  website:          http://juce.com
30  description:      Showcases in-app purchases features. To run this demo you must enable the
31                    "In-App Purchases Capability" option in the Projucer exporter.
32 
33  dependencies:     juce_audio_basics, juce_audio_devices, juce_audio_formats,
34                    juce_audio_processors, juce_audio_utils, juce_core,
35                    juce_cryptography, juce_data_structures, juce_events,
36                    juce_graphics, juce_gui_basics, juce_gui_extra,
37                    juce_product_unlocking
38  exporters:        xcode_mac, xcode_iphone, androidstudio
39 
40  moduleFlags:      JUCE_STRICT_REFCOUNTEDPOINTER=1
41                    JUCE_IN_APP_PURCHASES=1
42 
43  type:             Component
44  mainClass:        InAppPurchasesDemo
45 
46  useLocalCopy:     1
47 
48  END_JUCE_PIP_METADATA
49 
50 *******************************************************************************/
51 
52 #pragma once
53 
54 #include "../Assets/DemoUtilities.h"
55 
56 /*
57     To finish the setup of this demo, do the following in the Projucer project:
58 
59     1. In the project settings, set the "Bundle Identifier" to com.rmsl.juceInAppPurchaseSample
60     2. In the Android exporter settings, change the following settings:
61          - "In-App Billing" - Enabled
62          - "Key Signing: key.store" - path to InAppPurchase.keystore file in examples/Assets/Signing
63          - "Key Signing: key.store.password" - amazingvoices
64          - "Key Signing: key-alias" - InAppPurchase
65          - "Key Signing: key.alias.password" - amazingvoices
66     3. Re-save the project
67 */
68 
69 //==============================================================================
70 class VoicePurchases      : private InAppPurchases::Listener
71 {
72 public:
73     //==============================================================================
74     struct VoiceProduct
75     {
76         const char* identifier;
77         const char* humanReadable;
78         bool isPurchased, priceIsKnown, purchaseInProgress;
79         String purchasePrice;
80     };
81 
82     //==============================================================================
VoicePurchases(AsyncUpdater & asyncUpdater)83     VoicePurchases (AsyncUpdater& asyncUpdater)
84          : guiUpdater (asyncUpdater)
85     {
86         voiceProducts = Array<VoiceProduct>(
87                         { VoiceProduct {"robot",  "Robot",  true,   true,  false, "Free" },
88                           VoiceProduct {"jules",  "Jules",  false,  false, false, "Retrieving price..." },
89                           VoiceProduct {"fabian", "Fabian", false,  false, false, "Retrieving price..." },
90                           VoiceProduct {"ed",     "Ed",     false,  false, false, "Retrieving price..." },
91                           VoiceProduct {"lukasz", "Lukasz", false,  false, false, "Retrieving price..." },
92                           VoiceProduct {"jb",     "JB",     false,  false, false, "Retrieving price..." } });
93     }
94 
~VoicePurchases()95     ~VoicePurchases() override
96     {
97         InAppPurchases::getInstance()->removeListener (this);
98     }
99 
100     //==============================================================================
getPurchase(int voiceIndex)101     VoiceProduct getPurchase (int voiceIndex)
102     {
103         if (! havePurchasesBeenRestored)
104         {
105             havePurchasesBeenRestored = true;
106             InAppPurchases::getInstance()->addListener (this);
107 
108             InAppPurchases::getInstance()->restoreProductsBoughtList (true);
109         }
110 
111         return voiceProducts[voiceIndex];
112     }
113 
purchaseVoice(int voiceIndex)114     void purchaseVoice (int voiceIndex)
115     {
116         if (havePricesBeenFetched && isPositiveAndBelow (voiceIndex, voiceProducts.size()))
117         {
118             auto& product = voiceProducts.getReference (voiceIndex);
119 
120             if (! product.isPurchased)
121             {
122                 purchaseInProgress = true;
123 
124                 product.purchaseInProgress = true;
125                 InAppPurchases::getInstance()->purchaseProduct (product.identifier);
126 
127                 guiUpdater.triggerAsyncUpdate();
128             }
129         }
130     }
131 
getVoiceNames()132     StringArray getVoiceNames() const
133     {
134         StringArray names;
135 
136         for (auto& voiceProduct : voiceProducts)
137             names.add (voiceProduct.humanReadable);
138 
139         return names;
140     }
141 
isPurchaseInProgress()142     bool isPurchaseInProgress() const noexcept { return purchaseInProgress; }
143 
144 private:
145     //==============================================================================
productsInfoReturned(const Array<InAppPurchases::Product> & products)146     void productsInfoReturned (const Array<InAppPurchases::Product>& products) override
147     {
148         if (! InAppPurchases::getInstance()->isInAppPurchasesSupported())
149         {
150             for (auto idx = 1; idx < voiceProducts.size(); ++idx)
151             {
152                 auto& voiceProduct = voiceProducts.getReference (idx);
153 
154                 voiceProduct.isPurchased  = false;
155                 voiceProduct.priceIsKnown = false;
156                 voiceProduct.purchasePrice = "In-App purchases unavailable";
157             }
158 
159             AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon,
160                                               "In-app purchase is unavailable!",
161                                               "In-App purchases are not available. This either means you are trying "
162                                               "to use IAP on a platform that does not support IAP or you haven't setup "
163                                               "your app correctly to work with IAP.",
164                                               "OK");
165         }
166         else
167         {
168             for (auto product : products)
169             {
170                 auto idx = findVoiceIndexFromIdentifier (product.identifier);
171 
172                 if (isPositiveAndBelow (idx, voiceProducts.size()))
173                 {
174                     auto& voiceProduct = voiceProducts.getReference (idx);
175 
176                     voiceProduct.priceIsKnown = true;
177                     voiceProduct.purchasePrice = product.price;
178                 }
179             }
180 
181             AlertWindow::showMessageBoxAsync (AlertWindow::WarningIcon,
182                                               "Your credit card will be charged!",
183                                               "You are running the sample code for JUCE In-App purchases. "
184                                               "Although this is only sample code, it will still CHARGE YOUR CREDIT CARD!",
185                                               "Understood!");
186         }
187 
188         guiUpdater.triggerAsyncUpdate();
189     }
190 
productPurchaseFinished(const PurchaseInfo & info,bool success,const String &)191     void productPurchaseFinished (const PurchaseInfo& info, bool success, const String&) override
192     {
193         purchaseInProgress = false;
194 
195         auto idx = findVoiceIndexFromIdentifier (info.purchase.productId);
196 
197         if (isPositiveAndBelow (idx, voiceProducts.size()))
198         {
199             auto& voiceProduct = voiceProducts.getReference (idx);
200 
201             voiceProduct.isPurchased = success;
202             voiceProduct.purchaseInProgress = false;
203         }
204         else
205         {
206             // On failure Play Store will not tell us which purchase failed
207             for (auto& voiceProduct : voiceProducts)
208                 voiceProduct.purchaseInProgress = false;
209         }
210 
211         guiUpdater.triggerAsyncUpdate();
212     }
213 
purchasesListRestored(const Array<PurchaseInfo> & infos,bool success,const String &)214     void purchasesListRestored (const Array<PurchaseInfo>& infos, bool success, const String&) override
215     {
216         if (success)
217         {
218             for (auto& info : infos)
219             {
220                 auto idx = findVoiceIndexFromIdentifier (info.purchase.productId);
221 
222                 if (isPositiveAndBelow (idx, voiceProducts.size()))
223                 {
224                     auto& voiceProduct = voiceProducts.getReference (idx);
225 
226                     voiceProduct.isPurchased = true;
227                 }
228             }
229 
230             guiUpdater.triggerAsyncUpdate();
231         }
232 
233         if (! havePricesBeenFetched)
234         {
235             havePricesBeenFetched = true;
236             StringArray identifiers;
237 
238             for (auto& voiceProduct : voiceProducts)
239                 identifiers.add (voiceProduct.identifier);
240 
241             InAppPurchases::getInstance()->getProductsInformation (identifiers);
242         }
243     }
244 
245     //==============================================================================
findVoiceIndexFromIdentifier(String identifier)246     int findVoiceIndexFromIdentifier (String identifier) const
247     {
248         identifier = identifier.toLowerCase();
249 
250         for (auto i = 0; i < voiceProducts.size(); ++i)
251             if (String (voiceProducts.getReference (i).identifier) == identifier)
252                 return i;
253 
254         return -1;
255     }
256 
257     //==============================================================================
258     AsyncUpdater& guiUpdater;
259     bool havePurchasesBeenRestored = false, havePricesBeenFetched = false, purchaseInProgress = false;
260     Array<VoiceProduct> voiceProducts;
261 
262     JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (VoicePurchases)
263 };
264 
265 //==============================================================================
266 class PhraseModel : public ListBoxModel
267 {
268 public:
PhraseModel()269     PhraseModel() {}
270 
getNumRows()271     int getNumRows() override    { return phrases.size(); }
272 
paintListBoxItem(int row,Graphics & g,int w,int h,bool isSelected)273     void paintListBoxItem (int row, Graphics& g, int w, int h, bool isSelected) override
274     {
275         Rectangle<int> r (0, 0, w, h);
276 
277         auto& lf = Desktop::getInstance().getDefaultLookAndFeel();
278         g.setColour (lf.findColour (isSelected ? (int) TextEditor::highlightColourId : (int) ListBox::backgroundColourId));
279         g.fillRect (r);
280 
281         g.setColour (lf.findColour (ListBox::textColourId));
282 
283         g.setFont (18);
284 
285         String phrase = (isPositiveAndBelow (row, phrases.size()) ? phrases[row] : String{});
286         g.drawText (phrase, 10, 0, w, h, Justification::centredLeft);
287     }
288 
289 private:
290     StringArray phrases {"I love JUCE!", "The five dimensions of touch", "Make it fast!"};
291 
292     JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (PhraseModel)
293 };
294 
295 //==============================================================================
296 class VoiceModel  : public ListBoxModel
297 {
298 public:
299     //==============================================================================
300     class VoiceRow  : public Component,
301                       private Timer
302     {
303     public:
VoiceRow(VoicePurchases & voicePurchases)304         VoiceRow (VoicePurchases& voicePurchases) : purchases (voicePurchases)
305         {
306             addAndMakeVisible (nameLabel);
307             addAndMakeVisible (purchaseButton);
308             addAndMakeVisible (priceLabel);
309 
310             purchaseButton.onClick = [this] { clickPurchase(); };
311 
312             voices = purchases.getVoiceNames();
313 
314             setSize (600, 33);
315         }
316 
paint(Graphics & g)317         void paint (Graphics& g) override
318         {
319             auto r = getLocalBounds().reduced (4);
320             {
321                 auto voiceIconBounds = r.removeFromLeft (r.getHeight());
322                 g.setColour (Colours::black);
323                 g.drawRect (voiceIconBounds);
324 
325                 voiceIconBounds.reduce (1, 1);
326                 g.setColour (hasBeenPurchased ? Colours::white : Colours::grey);
327                 g.fillRect (voiceIconBounds);
328 
329                 g.drawImage (avatar, voiceIconBounds.toFloat());
330 
331                 if (! hasBeenPurchased)
332                 {
333                     g.setColour (Colours::white.withAlpha (0.8f));
334                     g.fillRect (voiceIconBounds);
335 
336                     if (purchaseInProgress)
337                         getLookAndFeel().drawSpinningWaitAnimation (g, Colours::darkgrey,
338                                                                     voiceIconBounds.getX(),
339                                                                     voiceIconBounds.getY(),
340                                                                     voiceIconBounds.getWidth(),
341                                                                     voiceIconBounds.getHeight());
342                 }
343             }
344         }
345 
resized()346         void resized() override
347         {
348             auto r = getLocalBounds().reduced (4 + 8, 4);
349             auto h = r.getHeight();
350             auto w = static_cast<int> (h * 1.5);
351 
352             r.removeFromLeft (h);
353             purchaseButton.setBounds (r.removeFromRight (w).withSizeKeepingCentre (w, h / 2));
354 
355             nameLabel.setBounds (r.removeFromTop (18));
356             priceLabel.setBounds (r.removeFromTop (18));
357         }
358 
update(int rowNumber,bool rowIsSelected)359         void update (int rowNumber, bool rowIsSelected)
360         {
361             isSelected  = rowIsSelected;
362             rowSelected = rowNumber;
363 
364             if (isPositiveAndBelow (rowNumber, voices.size()))
365             {
366                 auto imageResourceName = voices[rowNumber] + ".png";
367 
368                 nameLabel.setText (voices[rowNumber], NotificationType::dontSendNotification);
369 
370                 auto purchase = purchases.getPurchase (rowNumber);
371                 hasBeenPurchased = purchase.isPurchased;
372                 purchaseInProgress = purchase.purchaseInProgress;
373 
374                 if (purchaseInProgress)
375                     startTimer (1000 / 50);
376                 else
377                     stopTimer();
378 
379                 nameLabel.setFont (Font (16).withStyle (Font::bold | (hasBeenPurchased ? 0 : Font::italic)));
380                 nameLabel.setColour (Label::textColourId, hasBeenPurchased ? Colours::white : Colours::grey);
381 
382                 priceLabel.setFont (Font (10).withStyle (purchase.priceIsKnown ? 0 : Font::italic));
383                 priceLabel.setColour (Label::textColourId, hasBeenPurchased ? Colours::white : Colours::grey);
384                 priceLabel.setText (purchase.purchasePrice, NotificationType::dontSendNotification);
385 
386                 if (rowNumber == 0)
387                 {
388                     purchaseButton.setButtonText ("Internal");
389                     purchaseButton.setEnabled (false);
390                 }
391                 else
392                 {
393                     purchaseButton.setButtonText (hasBeenPurchased ? "Purchased" : "Purchase");
394                     purchaseButton.setEnabled (! hasBeenPurchased && purchase.priceIsKnown);
395                 }
396 
397                 setInterceptsMouseClicks (! hasBeenPurchased, ! hasBeenPurchased);
398 
399                 if (auto fileStream = createAssetInputStream (String ("Purchases/" + String (imageResourceName)).toRawUTF8()))
400                     avatar = PNGImageFormat().decodeImage (*fileStream);
401             }
402         }
403     private:
404         //==============================================================================
clickPurchase()405         void clickPurchase()
406         {
407             if (rowSelected >= 0)
408             {
409                 if (! hasBeenPurchased)
410                 {
411                     purchases.purchaseVoice (rowSelected);
412                     purchaseInProgress = true;
413                     startTimer (1000 / 50);
414                 }
415             }
416         }
417 
timerCallback()418         void timerCallback() override   { repaint(); }
419 
420         //==============================================================================
421         bool isSelected = false, hasBeenPurchased = false, purchaseInProgress = false;
422         int rowSelected = -1;
423         Image avatar;
424 
425         StringArray voices;
426 
427         VoicePurchases& purchases;
428 
429         Label nameLabel, priceLabel;
430         TextButton purchaseButton {"Purchase"};
431 
432         JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (VoiceRow)
433     };
434 
435     //==============================================================================
VoiceModel(VoicePurchases & voicePurchases)436     VoiceModel (VoicePurchases& voicePurchases) : purchases (voicePurchases)
437     {
438         voiceProducts = purchases.getVoiceNames();
439     }
440 
getNumRows()441     int getNumRows() override    { return voiceProducts.size(); }
442 
refreshComponentForRow(int row,bool selected,Component * existing)443     Component* refreshComponentForRow (int row, bool selected, Component* existing) override
444     {
445         if (isPositiveAndBelow (row, voiceProducts.size()))
446         {
447             if (existing == nullptr)
448                 existing = new VoiceRow (purchases);
449 
450             if (auto* voiceRow = dynamic_cast<VoiceRow*> (existing))
451                 voiceRow->update (row, selected);
452 
453             return existing;
454         }
455 
456         return nullptr;
457     }
458 
paintListBoxItem(int,Graphics & g,int w,int h,bool isSelected)459     void paintListBoxItem (int, Graphics& g, int w, int h, bool isSelected) override
460     {
461         auto r = Rectangle<int> (0, 0, w, h).reduced (4);
462 
463         auto& lf = Desktop::getInstance().getDefaultLookAndFeel();
464         g.setColour (lf.findColour (isSelected ? (int) TextEditor::highlightColourId : (int) ListBox::backgroundColourId));
465         g.fillRect (r);
466     }
467 
468 private:
469     StringArray voiceProducts;
470 
471     VoicePurchases& purchases;
472 
473     JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (VoiceModel)
474 };
475 
476 //==============================================================================
477 class InAppPurchasesDemo : public Component,
478                            private AsyncUpdater
479 {
480 public:
InAppPurchasesDemo()481     InAppPurchasesDemo()
482     {
483         Desktop::getInstance().getDefaultLookAndFeel().setUsingNativeAlertWindows (true);
484 
485         dm.addAudioCallback (&player);
486         dm.initialiseWithDefaultDevices (0, 2);
487 
488         setOpaque (true);
489 
490         phraseListBox.setModel (phraseModel.get());
491         voiceListBox .setModel (voiceModel.get());
492 
493         phraseListBox.setRowHeight (33);
494         phraseListBox.selectRow (0);
495         phraseListBox.updateContent();
496 
497         voiceListBox.setRowHeight (66);
498         voiceListBox.selectRow (0);
499         voiceListBox.updateContent();
500         voiceListBox.getViewport()->setScrollOnDragEnabled (true);
501 
502         addAndMakeVisible (phraseLabel);
503         addAndMakeVisible (phraseListBox);
504         addAndMakeVisible (playStopButton);
505         addAndMakeVisible (voiceLabel);
506         addAndMakeVisible (voiceListBox);
507 
508         playStopButton.onClick = [this] { playStopPhrase(); };
509 
510         soundNames = purchases.getVoiceNames();
511 
512        #if JUCE_ANDROID || JUCE_IOS
513         auto screenBounds = Desktop::getInstance().getDisplays().getPrimaryDisplay()->userArea;
514         setSize (screenBounds.getWidth(), screenBounds.getHeight());
515        #else
516         setSize (800, 600);
517        #endif
518     }
519 
~InAppPurchasesDemo()520     ~InAppPurchasesDemo() override
521     {
522         dm.closeAudioDevice();
523         dm.removeAudioCallback (&player);
524     }
525 
526 private:
527     //==============================================================================
handleAsyncUpdate()528     void handleAsyncUpdate() override
529     {
530         voiceListBox.updateContent();
531         voiceListBox.setEnabled (! purchases.isPurchaseInProgress());
532         voiceListBox.repaint();
533     }
534 
535     //==============================================================================
resized()536     void resized() override
537     {
538         auto r = getLocalBounds().reduced (20);
539 
540         {
541             auto phraseArea = r.removeFromTop (r.getHeight() / 2);
542 
543             phraseLabel   .setBounds (phraseArea.removeFromTop (36).reduced (0, 10));
544             playStopButton.setBounds (phraseArea.removeFromBottom (50).reduced (0, 10));
545             phraseListBox .setBounds (phraseArea);
546         }
547 
548         {
549             auto voiceArea = r;
550 
551             voiceLabel  .setBounds (voiceArea.removeFromTop (36).reduced (0, 10));
552             voiceListBox.setBounds (voiceArea);
553         }
554     }
555 
paint(Graphics & g)556     void paint (Graphics& g) override
557     {
558         g.fillAll (Desktop::getInstance().getDefaultLookAndFeel()
559                       .findColour (ResizableWindow::backgroundColourId));
560     }
561 
562     //==============================================================================
playStopPhrase()563     void playStopPhrase()
564     {
565         auto idx = voiceListBox.getSelectedRow();
566         if (isPositiveAndBelow (idx, soundNames.size()))
567         {
568             auto assetName = "Purchases/" + soundNames[idx] + String (phraseListBox.getSelectedRow()) + ".ogg";
569 
570             if (auto fileStream = createAssetInputStream (assetName.toRawUTF8()))
571             {
572                 currentPhraseData.reset();
573                 fileStream->readIntoMemoryBlock (currentPhraseData);
574 
575                 player.play (currentPhraseData.getData(), currentPhraseData.getSize());
576             }
577         }
578     }
579 
580     //==============================================================================
581     StringArray soundNames;
582 
583     Label phraseLabel                          { "phraseLabel", NEEDS_TRANS ("Phrases:") };
584     ListBox phraseListBox                      { "phraseListBox" };
585     std::unique_ptr<ListBoxModel> phraseModel  { new PhraseModel() };
586     TextButton playStopButton                  { "Play" };
587 
588     SoundPlayer player;
589     VoicePurchases purchases                   { *this };
590     AudioDeviceManager dm;
591 
592     Label voiceLabel                           { "voiceLabel", NEEDS_TRANS ("Voices:") };
593     ListBox voiceListBox                       { "voiceListBox" };
594     std::unique_ptr<VoiceModel> voiceModel     { new VoiceModel (purchases) };
595 
596     MemoryBlock currentPhraseData;
597 
598     JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (InAppPurchasesDemo)
599 };
600