Hebrew version · English chapter index
This is the last chapter in the game sequence. When a purchase in the store is successful, the application will save the player’s name and send only three values to a Cloud Function: name, number of Pushers and Lifetime. The teacher’s function will compose the title and message text and send them to everyone subscribed to the topic - including the device that sent it, so that it is easy to check.
Responsibility boundary
flowchart LR
A["Android"] -->|"name, pusherCount, lifetimeCircles"| F["sendPusherBrag"]
F --> V["validation + cooldown"]
V --> T["teacher-owned title and body"]
T --> M["FCM topic"]
M --> A
M --> O["other devices"]
The client does not send title, body or topic. Therefore a student can choose the values that the contract allows, but cannot turn the function into a service that sends free text to the entire class.
The values still come from the user’s device and are not proof of real achievement. Without Authentication and an authoritative database someone could modify the client and send false values. This is suitable for a classroom brag, not a competitive leaderboard.
1. Add a name to the shop window
In the Android window, create the file dialog_pusher_shop.xml under app > res > layout:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="8dp"
android:paddingBottom="4dp">
<TextView
android:id="@+id/shopDetailsText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/playerNameLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/player_name_hint"
app:counterEnabled="true"
app:counterMaxLength="30">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/playerNameInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName"
android:maxLength="30"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
And in app > res > values > strings.xml:
<string name="player_name_hint">Player name</string>
<string name="player_name_required">Enter a name before hiring</string>
<string name="brag_sent">Brag sent!</string>
<string name="brag_failed">Pusher hired, but the brag could not be sent</string>
The name is inside a window that already belongs to the purchase event, so we do not add another Activity or row of buttons to the crowded screen.
2. Replace the store message with View Binding
In MainActivity add:
import com.example.collectcircles.databinding.DialogPusherShopBinding;
And a field for the key:
private static final String PLAYER_NAME_KEY = "player_name";
Before
AlertDialog dialog =
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.shop_title)
.setMessage(getString(
R.string.shop_message,
gameProgress.getPusherCount(),
gameProgress.getCirclesBalance(),
gameProgress.getNextPusherPrice()
))
.setNegativeButton(R.string.close, null)
.setPositiveButton(R.string.hire_pusher, null)
.create();
After
DialogPusherShopBinding shopBinding =
DialogPusherShopBinding.inflate(
getLayoutInflater()
);
shopBinding.shopDetailsText.setText(getString(
R.string.shop_message,
gameProgress.getPusherCount(),
gameProgress.getCirclesBalance(),
gameProgress.getNextPusherPrice()
));
shopBinding.playerNameInput.setText(
preferences.getString(PLAYER_NAME_KEY, "")
);
AlertDialog dialog =
new MaterialAlertDialogBuilder(this)
.setTitle(R.string.shop_title)
.setView(shopBinding.getRoot())
.setNegativeButton(R.string.close, null)
.setPositiveButton(R.string.hire_pusher, null)
.create();
View Binding is also automatically created for the dialog layout file. findViewById is not needed.
3. Validate and save the name only for a successful purchase
Inside the listener of the positive button, before buyPusher(), read the name:
String playerName = String.valueOf(
shopBinding.playerNameInput.getText()
).trim();
if (playerName.isEmpty()) {
shopBinding.playerNameLayout.setError(
getString(R.string.player_name_required)
);
return;
}
shopBinding.playerNameLayout.setError(null);
After the purchase was successful:
preferences.edit()
.putString(PLAYER_NAME_KEY, playerName)
.apply();
If there are not enough circles, the button is already disabled. If the name is missing, we neither buy nor send. Each Brag therefore corresponds to a purchase made in that same dialog.
4. The first callable that receives data
Add imports:
import java.util.HashMap;
import java.util.Map;
And add to MainActivity:
private void sendPusherBrag(String playerName) {
Map<String, Object> data = new HashMap<>();
data.put("name", playerName);
data.put("pusherCount", gameProgress.getPusherCount());
data.put(
"lifetimeCircles",
gameProgress.getLifetimeCircles()
);
FirebaseFunctions.getInstance()
.getHttpsCallable("sendPusherBrag")
.call(data)
.addOnCompleteListener(task -> {
int message = task.isSuccessful()
? R.string.brag_sent
: R.string.brag_failed;
Toast.makeText(
this,
message,
Toast.LENGTH_SHORT
).show();
});
}
According to the official Firebase callable documentation, the Map passed to call(data) arrives on the server as request.data.
Do not add a title, message content or topic to the map. The three keys are the entire data contract; the teacher controls the wording and destination from within the function.
5. Connect the purchase event
Before
if (gameProgress.buyPusher()) {
showProgress();
binding.gameBoard.setPusherCount(
gameProgress.getPusherCount()
);
Toast.makeText(
this,
R.string.pusher_hired,
Toast.LENGTH_SHORT
).show();
dialog.dismiss();
}
After
if (gameProgress.buyPusher()) {
preferences.edit()
.putString(PLAYER_NAME_KEY, playerName)
.apply();
showProgress();
binding.gameBoard.setPusherCount(
gameProgress.getPusherCount()
);
Toast.makeText(
this,
R.string.pusher_hired,
Toast.LENGTH_SHORT
).show();
sendPusherBrag(playerName);
dialog.dismiss();
}
The local purchase precedes the network call. If the Callable fails, the Pusher remains owned by the user; there is no reason to cancel a valid local purchase because of a temporary communication problem.
6. What is asynchronous here?
sequenceDiagram
participant U as User
participant UI as UI thread
participant F as Firebase client
participant C as Cloud Function
participant M as FCM topic
U->>UI: Hire pusher
UI->>UI: validate name + buy + persist
UI->>F: call(data)
F-->>UI: returns a Task immediately
Note over UI: dialog closes, game remains responsive
F->>C: HTTPS callable request
C->>C: validate + create text
C->>M: send notification
C-->>F: sent result
F->>UI: onComplete callback
- Clicking, checking the name, buying and updating Views run on the UI thread.
call(data)begins asynchronous I/O and returns aTask; it does not block the screen until the server answers.- The Cloud Function runs in a Node.js process in the cloud, not on the phone.
addOnCompleteListenerruns after the end of the task and displays a short toast.- FCM fanout is a separate operation: the callable may have already succeeded, but delivery of the notification to devices may be delayed.
There is no new Thread in the Activity code, and no WorkManager is needed: brag belongs to clicking while the application is open. If a process is killed after the purchase and before the end of the call, we do not guarantee a retry - this is an appropriate compromise for a social message that is not critical data.
7. The teacher’s function
In this section, students need to understand the contract, while the teacher maintains and deploys the code.
Add to functions/index.js:
const BRAG_COOLDOWN_MILLIS = 10_000;
let lastBragTime = 0;
function readBragData(request) {
const data = request.data;
if (data === null || typeof data !== "object" || Array.isArray(data)) {
throw new HttpsError("invalid-argument", "Brag data must be an object.");
}
const allowedKeys = ["name", "pusherCount", "lifetimeCircles"];
if (Object.keys(data).some((key) => !allowedKeys.includes(key))) {
throw new HttpsError("invalid-argument", "Unexpected brag field.");
}
const name = typeof data.name === "string" ? data.name.trim() : "";
if (name.length < 1 || name.length > 30 || /[\r\n]/u.test(name)) {
throw new HttpsError("invalid-argument", "Invalid player name.");
}
if (!Number.isSafeInteger(data.pusherCount) ||
data.pusherCount < 1 || data.pusherCount > 58) {
throw new HttpsError("invalid-argument", "Invalid pusher count.");
}
if (!Number.isSafeInteger(data.lifetimeCircles) ||
data.lifetimeCircles < 0) {
throw new HttpsError("invalid-argument", "Invalid lifetime total.");
}
return {
name,
pusherCount: data.pusherCount,
lifetimeCircles: data.lifetimeCircles,
};
}
exports.sendPusherBrag = onCall(FUNCTION_OPTIONS, async (request) => {
const brag = readBragData(request);
const now = Date.now();
if (now - lastBragTime < BRAG_COOLDOWN_MILLIS) {
throw new HttpsError(
"resource-exhausted",
"Please wait before sending another brag.",
);
}
const messageId = await getMessaging().send({
topic: NOTIFICATION_TOPIC,
notification: {
title: "A new pusher joined the crew!",
body: `${brag.name} now employs ${brag.pusherCount} pushers ` +
`after collecting ${brag.lifetimeCircles} circles.`,
},
android: {
priority: "high",
notification: {
channelId: "circle_invitations",
icon: "ic_launcher_foreground",
},
},
});
lastBragTime = now;
return {sent: true, messageId};
});
The function:
- Accepts only three recognized fields;
- Limits name to 30 characters and prohibits new lines;
- Requires integers within JavaScript’s safe range;
- Builds all the text on the server;
- Sends to
circle_notifications, to which the sender is also already registered; - Rate-limits calls in the same server instance.
The cooldown is not a full security mechanism and can be reset on a cold start. The real defense in a future project will be App Check, Authentication and authoritative state on the server. Here, it is a simple guardrail added to an expensive purchase that happens infrequently.
8. Keep the server text in the foreground as well
When the application is in the background, Android displays a notification payload by itself. When in the foreground, onMessageReceived() is called. The old code calls Notifications.show(this), replacing the brag with the standard invitation text.
Add to Notifications:
private static final int REMOTE_NOTIFICATION_ID = 3;
public static void showRemote(
Context context,
String title,
String body
) {
createChannel(context);
NotificationManager manager = context.getSystemService(
NotificationManager.class
);
Notification notification = new Notification.Builder(
context,
CHANNEL_ID
)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(title)
.setContentText(body)
.setAutoCancel(true)
.build();
manager.notify(REMOTE_NOTIFICATION_ID, notification);
}
And in CircleMessagingService replace:
if (message.getNotification() != null) {
- Notifications.show(this);
+ Notifications.showRemote(
+ this,
+ message.getNotification().getTitle(),
+ message.getNotification().getBody()
+ );
}
Here we allow remote text because it comes from FCM and the teacher’s function, rather than directly from a text field sent to the topic by the phone.
9. Testing and deployment
Syntax check for functions:
cd functions
npm run check
Deploy only the new function:
firebase deploy --only functions:sendPusherBrag
Targeted deployment does not modify or delete invitation functions that are already running. This is also the targeted deployment pattern recommended by Firebase.
It is possible to check the validation without sending a real message: a call with an empty name should return INVALID_ARGUMENT before the FCM operation. There is no need to make an automated successful call, because it will broadcast immediately to all subscribers.
After deployment:
- Open the shop when there are enough circles.
- Enter a name and buy a Pusher.
- Make sure the Pusher is added immediately, even before the network reply.
- Make sure a Toast reports whether sending the Brag succeeded or failed.
- Check that the sending device receives a notification with name, number of Pushers and Lifetime.
- Test once with the app in the foreground and once in the background. The server’s message text should be preserved in both cases.
- If there is another device subscribed to the topic, make sure it also receives the same notification.
Also run once:
.\gradlew.bat testDebugUnitTest assembleDebug
The game is now complete as a learning sequence: manual play, saved economy, animated pushers, offline production, scheduled WorkManager tasks, and a group Brag where the client sends data and the server controls the message.
Gemini tutor
Open Gem: CollectCircles Background Trainer to get hints, diagnostic questions and explanations appropriate to the stage you are on.