Mobile and app services: Android, purchases, notifications, analytics, and licensing
Building for Android and iOS, and wiring an app to the services around it.
Level: Intermediate to advanced
Platforms: Android, iOS, macOS (varies per section)
Most of these features need accounts and configuration on external services, and those steps change often. This guide gives the JUCE side of each, and points out what must be set up elsewhere. Check each platform's current documentation for the account-side steps.
Set up the project
The project below shows the screen details a mobile app has to cope with (size, orientation, scale, and DPI), links every module the later sections use, and runs on desktop as well as iOS. Use the desktop build to try the in-app purchase, notification, analytics, and unlocking code that the platform allows, and a phone or tablet for the rest.
This guide builds on Getting started. To skip the typing, download the starter project, MobileServicesDemo.zip, and unzip it. It contains exactly the four files below. If you prefer, create the MobileServicesDemo folder yourself and copy each file from this page. Unlike the other guides, this one changes tutorial 1's Main.cpp for mobile.
MobileServicesDemo/
├── CMakeLists.txt
├── Main.cpp
├── MainComponent.h
└── MainComponent.cppEither way, CMakeLists.txt expects JUCE to be cloned to ~/JUCE. If yours is elsewhere, change the path on the file (REAL_PATH ...) line. This CMakeLists.txt names the target MobileServicesDemo and links the modules this guide needs.
CMakeLists.txt
cmake_minimum_required(VERSION 3.22)
set(CMAKE_OSX_ARCHITECTURES "arm64" CACHE STRING "macOS architectures") # must come before project()
project(MOBILESERVICESDEMO VERSION 0.0.1)
file(REAL_PATH "~/JUCE" JUCE_DIR EXPAND_TILDE) # JUCE cloned to ~/JUCE; or use find_package (JUCE CONFIG REQUIRED)
add_subdirectory(${JUCE_DIR} JUCE)
juce_add_gui_app(MobileServicesDemo PRODUCT_NAME "Mobile Services Demo"
NEEDS_STORE_KIT TRUE # links StoreKit and enables juce::InAppPurchases on Apple platforms
PUSH_NOTIFICATIONS_ENABLED TRUE) # adds the push-notification entitlement on Apple platforms
target_sources(MobileServicesDemo PRIVATE Main.cpp MainComponent.cpp)
target_compile_definitions(MobileServicesDemo PRIVATE
JUCE_WEB_BROWSER=0
JUCE_USE_CURL=0
JUCE_PUSH_NOTIFICATIONS=1 # use the real juce::PushNotifications, not the no-op stub
JUCE_APPLICATION_NAME_STRING="$<TARGET_PROPERTY:MobileServicesDemo,JUCE_PRODUCT_NAME>"
JUCE_APPLICATION_VERSION_STRING="$<TARGET_PROPERTY:MobileServicesDemo,JUCE_VERSION>")
target_link_libraries(MobileServicesDemo
PRIVATE juce::juce_gui_extra
juce::juce_product_unlocking
juce::juce_analytics
PUBLIC juce::juce_recommended_config_flags
juce::juce_recommended_warning_flags)Main.cpp
#include "MainComponent.h"
class MobileServicesDemoApplication final : public juce::JUCEApplication
{
public:
const juce::String getApplicationName() override { return JUCE_APPLICATION_NAME_STRING; }
const juce::String getApplicationVersion() override { return JUCE_APPLICATION_VERSION_STRING; }
void initialise (const juce::String&) override
{
mainWindow.reset (new MainWindow (getApplicationName()));
}
void shutdown() override { mainWindow = nullptr; } // deletes the window
void systemRequestedQuit() override { quit(); }
class MainWindow final : public juce::DocumentWindow
{
public:
explicit MainWindow (juce::String name)
: DocumentWindow (name, juce::Colours::lightgrey, allButtons)
{
setUsingNativeTitleBar (true);
setContentOwned (new MainComponent(), true);
#if JUCE_IOS || JUCE_ANDROID
setFullScreen (true);
#else
setResizable (true, true);
centreWithSize (getWidth(), getHeight());
#endif
setVisible (true);
}
void closeButtonPressed() override
{
juce::JUCEApplication::getInstance()->systemRequestedQuit();
}
};
private:
std::unique_ptr<MainWindow> mainWindow;
};
START_JUCE_APPLICATION (MobileServicesDemoApplication)MainComponent.h
#pragma once
#include <juce_gui_extra/juce_gui_extra.h>
#include <juce_product_unlocking/juce_product_unlocking.h>
#include <juce_analytics/juce_analytics.h>
class MainComponent final : public juce::Component
{
public:
MainComponent();
void paint (juce::Graphics& g) override;
void resized() override;
private:
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MainComponent)
};MainComponent.cpp
#include "MainComponent.h"
MainComponent::MainComponent()
{
setSize (480, 640); // ignored on phones and tablets, where the window is full screen
}
void MainComponent::paint (juce::Graphics& g)
{
g.fillAll (getLookAndFeel().findColour (juce::ResizableWindow::backgroundColourId));
g.setColour (juce::Colours::white);
g.setFont (juce::FontOptions (18.0f));
juce::String text;
text << "Component: " << getWidth() << " x " << getHeight() << " logical pixels\n"
<< (getWidth() > getHeight() ? "Landscape" : "Portrait") << "\n";
if (auto* display = juce::Desktop::getInstance().getDisplays().getPrimaryDisplay())
text << "Display scale: " << display->scale << "\n"
<< "Display DPI: " << display->dpi << "\n"
<< "User area: " << display->userBounds.toString() << "\n";
juce::String orientation;
switch (juce::Desktop::getInstance().getCurrentOrientation())
{
case juce::Desktop::upright: orientation = "Upright"; break;
case juce::Desktop::upsideDown: orientation = "Upside down"; break;
case juce::Desktop::rotatedClockwise: orientation = "Rotated clockwise"; break;
case juce::Desktop::rotatedAntiClockwise: orientation = "Rotated anticlockwise"; break;
case juce::Desktop::allOrientations: break;
}
text << "Orientation: " << orientation;
g.drawFittedText (text, getLocalBounds().reduced (16), juce::Justification::topLeft, 10);
}
void MainComponent::resized()
{
repaint(); // the text above depends on the size
}iOS. The same project builds for iOS with CMake's Xcode generator. Give it your Apple developer team so Xcode can sign the app, then open the generated project and run it on a simulator or device:
cmake -B build-ios -G Xcode -DCMAKE_SYSTEM_NAME=iOS \
-DCMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM=YOUR_TEAM_ID
open build-ios/*.xcodeprojAndroid. JUCE's CMake API does not currently support Android targets, so Android projects come from the Projucer, as the next section describes. To try this project's code there, create a GUI Application in the Projucer, add the juce_product_unlocking and juce_analytics modules, add an Android exporter, and replace the generated Main.cpp and MainComponent files with the ones above. In MainComponent.h, change the include to #include <JuceHeader.h>, which is how Projucer projects reach the module headers.
Getting started with Android
Requirements (see the README for the supported versions): Android Studio, the Android SDK, and the NDK (NDK 26 for this JUCE version; minimum device Android 7, API level 24). Install them through Android Studio's SDK Manager.
- Install Android Studio and, from its SDK Manager, an SDK platform plus the NDK.
- Tell JUCE where they are. In the Projucer, use Global Search Paths to set the Android SDK (for example
~/Library/Android/sdkon macOS or%LOCALAPPDATA%\Android\sdkon Windows) and the NDK inside it. Correct paths show in white. - Create a project with an Android exporter, set the minimum SDK version to one you have installed, and save. The Projucer generates a Gradle project.
- Open the generated project in Android Studio and run it on an emulator or device.
For a GUI app, make the window fill the screen instead of centring a small window:
setUsingNativeTitleBar (true);
setContentOwned (new MainComponent(), true);
#if JUCE_IOS || JUCE_ANDROID
setFullScreen (true);
#else
setResizable (true, true);
centreWithSize (getWidth(), getHeight());
#endif
setVisible (true);(The CMake GuiApp example makes exactly this distinction.)
Managing screen sizes
Phones and tablets differ in physical size, resolution, and orientation, and a user can rotate at any time. JUCE scales its coordinate system by the display's pixel density, so a button of the same logical size is a similar physical size on different screens. The DPI reported by the OS is only approximate, though, so test on real devices. Query the display with Desktop::getInstance().getDisplays().getPrimaryDisplay() (scale, dpi, userBounds).
Four strategies, from simplest to most flexible:
- Resize the children. Lay children out from
getLocalBounds()inresized()(see Graphics, layout, and animation). It is easy, but an interface designed for a phone can become sparse on a tablet, or unreadably small on a tiny screen. - Scale the whole interface with a transform. Design at a fixed nominal size and scale it to fit:
void resized() override
{
const int contentWidth = 480, contentHeight = 640;
auto scale = juce::jmin ((float) getWidth() / (float) contentWidth,
(float) getHeight() / (float) contentHeight);
content.setTransform (juce::AffineTransform::scale (scale));
content.centreWithSize (contentWidth, contentHeight);
}- Different layouts per orientation. Test
getWidth() > getHeight()inresized()and call a portrait or landscape layout function (for example one column of controls versus two). Combine with a transform, choosing the nominal size per orientation. Restrict rotation withDesktop::setOrientationsEnabled(), and read it withgetCurrentOrientation(). - Different layouts per size class. Choose by the available width in logical pixels (compact, regular, and so on), and reuse the same child components in each arrangement.
Touch interfaces need bigger targets: enlarge slider thumbs with a custom LookAndFeel (getSliderThumbRadius()), increase spacing, and avoid interactions that depend on hover.
In-app purchases
juce::InAppPurchases (module juce_product_unlocking) wraps the Apple App Store and Google Play billing APIs behind one interface. Enable it with the module option JUCE_IN_APP_PURCHASES=1. In CMake, set NEEDS_STORE_KIT TRUE on the target, as the project above does, which defines it and links Apple's StoreKit framework on Apple platforms; in the Projucer, use the in-app purchases flag. Without it the class is not declared. The flow:
- In the store consoles (App Store Connect, Google Play Console), define your products and choose types: consumable, non-consumable, or subscription.
- In the app, check
isInAppPurchasesSupported(), register as aListener, and ask for product information. - Start purchases, then react to asynchronous results.
class Store final : private juce::InAppPurchases::Listener
{
public:
Store()
{
auto& iap = *juce::InAppPurchases::getInstance();
if (! iap.isInAppPurchasesSupported()) return;
iap.addListener (this);
iap.getProductsInformation ({ "com.example.app.pro", "com.example.app.pack1" });
iap.restoreProductsBoughtList (true); // find what the user already owns
}
~Store() override { juce::InAppPurchases::getInstance()->removeListener (this); }
void buy (const juce::String& productId)
{
juce::InAppPurchases::getInstance()->purchaseProduct (productId);
}
private:
void productsInfoReturned (const juce::Array<juce::InAppPurchases::Product>& products) override
{
for (auto& p : products)
DBG (p.identifier << ": " << p.title << " " << p.price); // build your store UI from these
}
void productPurchaseFinished (const PurchaseInfo& info, bool success, const juce::String& status) override
{
if (success)
for (auto& id : info.purchase.productIds)
unlock (id); // grant the item, then persist it
else
DBG ("Purchase failed: " << status);
}
void purchasesListRestored (const juce::Array<PurchaseInfo>& purchases, bool success, const juce::String&) override
{
if (success)
for (auto& p : purchases)
for (auto& id : p.purchase.productIds)
unlock (id);
}
void unlock (const juce::String& productId); // your logic
};- Every result is asynchronous, and listener callbacks run on the message thread. Never assume an item is owned until
productPurchaseFinished()says so. Use product IDs exactly as defined in the store. - Consumables must be consumed after delivery on Android (
consumePurchase()) so they can be bought again. - Always offer a "Restore purchases" action, and verify receipts on a server for anything valuable.
- Testing needs a signed build uploaded to the store's test track (Android) or a sandbox tester account (Apple), so expect to configure app IDs, bundle IDs, and signing before the first call returns anything.
Push and local notifications
juce::PushNotifications (module juce_gui_extra) handles both kinds:
- Local: the app asks the operating system to show or schedule a message. No network needed.
- Remote: a server pushes a message through Apple Push Notification service (iOS and macOS) or Firebase Cloud Messaging (Android).
class Notifier final : private juce::PushNotifications::Listener
{
public:
Notifier()
{
auto& pn = *juce::PushNotifications::getInstance();
pn.addListener (this);
juce::PushNotifications::Settings settings; // choose alerts, sounds, badges, categories
settings.allowAlert = true;
settings.allowBadge = true;
settings.allowSound = true;
pn.requestPermissionsWithSettings (settings); // asks the user on iOS, macOS, and Android 13+
}
~Notifier() override { juce::PushNotifications::getInstance()->removeListener (this); }
void sendLocal()
{
juce::PushNotifications::Notification n;
n.identifier = "reminder-1";
n.title = "Reminder";
n.body = "Your render has finished.";
n.badgeNumber = 1;
n.properties = juce::JSON::parse ("{ \"jobId\": 42 }"); // your own payload
juce::PushNotifications::getInstance()->sendLocalNotification (n);
}
private:
void handleNotification (bool isLocal, const juce::PushNotifications::Notification& n) override
{
DBG ((isLocal ? "Local: " : "Remote: ") << n.title << " / " << n.body);
}
void handleNotificationAction (bool, const juce::PushNotifications::Notification&,
const juce::String& actionIdentifier,
const juce::String& optionalResponse) override
{
DBG ("Action " << actionIdentifier << " " << optionalResponse);
}
void deviceTokenRefreshed (const juce::String& token) override
{
// Send this token to your server: it is the address for remote pushes
}
};- Permissions first. Notifications are off until the user allows them, and the settings you request (alert, sound, badge, action categories) are per platform.
areNotificationsEnabled()reports the current state. - Actions and customisation. A
Notificationcan carry action buttons (Notification::Action, optionally with text input), categories, a sound, a large icon, and grouping on Android, with channels (Settings::Channel) for Android's notification categories. - Remote setup. iOS and macOS need the Push Notifications capability, a signing identity, and a push certificate or key. Android needs a Firebase project and its
google-services.jsonin the app, plus the Projucer's remote-notifications option and resources listed individually in the exporter. Topic subscription (subscribeToTopic()) and upstream messages (sendUpstreamMessage()) are available on Android. - Custom sounds and icons must be bundled: as Xcode resources on Apple platforms, and as explicit resource entries on Android.
Analytics
juce_analytics collects events on a background thread and delivers them in batches to one or more destinations, so your UI thread never waits on the network.
auto& analytics = *juce::Analytics::getInstance();
analytics.setUserId ("anon-1234"); // avoid personal data
juce::StringPairArray userProperties;
userProperties.set ("group", "beta");
analytics.setUserProperties (userProperties);
analytics.addDestination (new MyDestination()); // Analytics owns it
analytics.logEvent ("startup", {}, 0);
juce::StringPairArray exportParams;
exportParams.set ("id", "export");
analytics.logEvent ("button_press", exportParams, 0);
// Automatic tracking of a button: logs "button_press" on every click
std::unique_ptr<juce::ButtonTracker> tracker
(new juce::ButtonTracker (exportButton, "button_press", exportParams));An event has a name, optional parameters, a type, a timestamp, a user ID, and user properties. A destination derives from ThreadedAnalyticsDestination:
class MyDestination final : public juce::ThreadedAnalyticsDestination
{
public:
MyDestination() : ThreadedAnalyticsDestination ("AnalyticsThread")
{
startAnalyticsThread (initialPeriodMs);
}
~MyDestination() override
{
juce::Thread::sleep (initialPeriodMs); // give the last batch a chance to be sent
stopAnalyticsThread (1000); // mandatory in every subclass
}
int getMaximumBatchSize() override { return 20; }
bool logBatchedEvents (const juce::Array<AnalyticsEvent>& events) override
{
// Turn events into one HTTP request for your analytics service, POST it
// (for example with juce::URL and WebInputStream), and return true on success.
// On failure, back off: periodMs *= 2; setBatchPeriod (periodMs);
return sendToServer (events);
}
void stopLoggingEvents() override
{
// The app is quitting: cancel any request in progress so shutdown does not hang
}
private:
// Called as the app shuts down: keep whatever could not be sent (quickly: the timeout in
// stopAnalyticsThread() covers this and stopLoggingEvents())
void saveUnloggedEvents (const std::deque<AnalyticsEvent>& eventsToSave) override
{
juce::XmlElement root ("events");
for (auto& e : eventsToSave)
{
auto* xml = root.createNewChildElement ("event");
xml->setAttribute ("name", e.name);
xml->setAttribute ("type", e.eventType);
xml->setAttribute ("timestamp", (int) e.timestamp);
xml->setAttribute ("user_id", e.userID);
auto* params = xml->createNewChildElement ("parameters");
for (auto& key : e.parameters.getAllKeys())
params->setAttribute (key, e.parameters[key]);
}
root.writeTo (savedEventsFile);
}
// Called on the analytics thread at start-up: these events are sent first
void restoreUnloggedEvents (std::deque<AnalyticsEvent>& restoredEventQueue) override
{
if (auto root = juce::XmlDocument::parse (savedEventsFile))
for (auto* xml : root->getChildIterator())
{
AnalyticsEvent e;
e.name = xml->getStringAttribute ("name");
e.eventType = xml->getIntAttribute ("type");
e.timestamp = (juce::uint32) xml->getIntAttribute ("timestamp");
e.userID = xml->getStringAttribute ("user_id");
if (auto* params = xml->getChildByName ("parameters"))
for (int i = 0; i < params->getNumAttributes(); ++i)
e.parameters.set (params->getAttributeName (i), params->getAttributeValue (i));
restoredEventQueue.push_back (e);
}
savedEventsFile.deleteFile();
}
juce::File savedEventsFile = juce::File::getSpecialLocation (juce::File::userApplicationDataDirectory)
.getChildFile ("MyApp").getChildFile ("analytics_events.xml");
static constexpr int initialPeriodMs = 1000;
bool sendToServer (const juce::Array<AnalyticsEvent>&);
};- Call
stopAnalyticsThread()in the destructor of every subclass, or the thread outlives the object. saveUnloggedEvents()andrestoreUnloggedEvents()are pure virtual: you must decide how to keep unsent events between runs (XML in the application data directory works well), so a lost connection does not lose data. Create the folder for the file before first use.- The original tutorial demonstrated a Google Analytics "Universal Analytics" destination. That service stopped processing data in 2023, so send events to your current analytics provider's HTTP API instead. The structure above does not change.
- Collect only what you need, prefer anonymous IDs, and follow the privacy law and store rules that apply to you (consent, disclosure, opt-out).
Locking and unlocking a product
juce_product_unlocking also supports online registration: the app sends a user's credentials to your server, which replies with a signed key that unlocks the product on that machine.
How it works: your server holds an RSA private key. After it authorises a user, it signs a reply containing the product and machine IDs. The app carries the matching public key and checks the signature. Only the private-key holder can produce a valid reply, so an attacker cannot forge one.
Implement a subclass of OnlineUnlockStatus:
class MarketplaceStatus final : public juce::OnlineUnlockStatus
{
public:
juce::String getProductID() override { return "MyApp"; }
bool doesProductIDMatch (const juce::String& id) override { return getProductID() == id; }
juce::String getWebsiteName() override { return "example.com"; }
juce::URL getServerAuthenticationURL() override { return juce::URL ("https://example.com/auth"); }
juce::RSAKey getPublicKey() override { return juce::RSAKey ("PUBLIC_KEY_HERE"); }
// Persist the unlock state between runs
void saveState (const juce::String& s) override { properties.setValue ("unlockState", s); }
juce::String getState() override { return properties.getValue ("unlockState"); }
juce::String readReplyFromWebserver (const juce::String& email, const juce::String& password) override;
private:
static juce::PropertiesFile::Options makeOptions()
{
juce::PropertiesFile::Options o;
o.applicationName = "MyApp";
o.filenameSuffix = ".settings";
return o;
}
juce::PropertiesFile properties { makeOptions() };
};Show the built-in OnlineUnlockForm (email and password) and dismiss it on success or cancel:
class UnlockForm final : public juce::OnlineUnlockForm
{
public:
explicit UnlockForm (MarketplaceStatus& s) : OnlineUnlockForm (s, "Please enter your email and password.") {}
void dismiss() override { setVisible (false); }
};
// In the UI: unlockButton.onClick = [this] { form.setVisible (true); };
// Poll or listen for the result: if (status.isUnlocked()) enableFeatures();The server replies with XML, either <MESSAGE message="Thanks!"><KEY>...</KEY></MESSAGE> (using KeyGeneration::generateKeyFile() with your private key) or <ERROR error="..."/>. Generate the RSA key pair with RSAKey::createKeyPair(), and keep the private key only on the server. For local testing you need HTTPS, so a self-signed certificate plus a TLS tunnel such as stunnel in front of a development web server works.
Security notes:
- Check
isUnlocked()at several points in your code, at the places where protected features run, rather than once at start-up: a single check is easy to patch out. - No client-side scheme is unbreakable. Treat this as a way to keep honest users honest, and combine it with sensible pricing and support.
- Never ship the private key, and use HTTPS for the server.
Build and run
With all the files in place, configure and build from the project folder:
cmake -B build
cmake --build buildThe build puts the finished app in build/MobileServicesDemo_artefacts/. With CMake's default generator (Makefiles on macOS and Linux):
| Platform | Run it with |
|---|---|
| macOS | open "build/MobileServicesDemo_artefacts/Mobile Services Demo.app" |
| Linux | ./build/MobileServicesDemo_artefacts/Mobile\ Services\ Demo |
The Xcode generator is multi-config and adds a configuration folder, for example build/MobileServicesDemo_artefacts/Debug/Mobile Services Demo.app; build with cmake --build build --config Debug.
"The application cannot be opened because its executable is missing"? CMake creates the empty
.appbundle at the start of the build and only fills in the executable when compiling and linking succeed. Ifcmake --build buildreported errors, fix them and build again, then re-runopen. Check that the last lines of the build output sayBuilt target MobileServicesDemo.
"use of undeclared identifier 'juce'"? CMake projects have no
JuceHeader.h, so every source file must include the module headers it uses.MainComponent.hincludes them andMain.cppincludesMainComponent.h. Without those includes the compiler does not know whatjuce::,std::, orSTART_JUCE_APPLICATIONmean.
Sources
Condensed from the JUCE tutorials Getting started with Android, Managing Android screen sizes, In-App Purchases on desktop and mobile devices, Push Notifications on desktop and mobile devices, App analytics collection, and Unlock your plugins through online registration, Copyright (c) Raw Material Software Limited, ISC licence. See the attribution notice.