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