Skip to content

Commit 23c67a0

Browse files
dadachiclaude
andcommitted
Add iOS/Android push notification integration guides
Two implementation guides for the paid iOS/Android substrate clients (issue #58 PRs #3, #4) — what API contract to fulfill, capability / project setup, permission UX, token lifecycle (receive → POST → refresh → DELETE on sign-out), foreground vs background handling, testing tips. Each guide opens with a scope summary clarifying which concerns live in this Rails repo (notifier, AASM trigger, credentials) vs the client repo (token lifecycle, channels, permission UX). Free clients intentionally do not get push registration code — paid-edition gate is at the client layer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d33b0f0 commit 23c67a0

2 files changed

Lines changed: 476 additions & 0 deletions

File tree

docs/push-android-integration.md

Lines changed: 287 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,287 @@
1+
# Push notifications: Android client integration
2+
3+
How a paid Android substrate client (e.g. the closed-source `NativeAppTemplate` Android app) integrates with this API to receive push notifications via Firebase Cloud Messaging (FCM).
4+
5+
This is the implementation guide for **PR #4** of [issue #58](https://github.com/nativeapptemplate/nativeapptemplateapi/issues/58). The free Android substrate intentionally does not get push registration code — push is a paid-edition feature, gated at the client layer (the API endpoint is open; the gate is "you have to write the client to use it").
6+
7+
---
8+
9+
## Scope summary
10+
11+
| Concern | Where it lives |
12+
|---|---|
13+
| FCM service account JSON provisioning | This Rails repo's encrypted credentials (`bin/rails credentials:edit`) |
14+
| Notification payload composition | `ItemTagNotifier` in this repo |
15+
| Notification delivery | Action Push Native, fired by `ItemTag`'s AASM `complete` event |
16+
| Device token registration API | `POST /api/v1/shopkeeper/devices` (see [`docs/openapi.yaml`](openapi.yaml)) |
17+
| Android-side token receipt + lifecycle | The paid Android client (this doc) |
18+
| Permission UX (API 33+) | The paid Android client (this doc) |
19+
| Notification channels | The paid Android client (this doc) |
20+
| Foreground / background presentation | The paid Android client (this doc) |
21+
22+
---
23+
24+
## API contract recap
25+
26+
The Android client posts the FCM registration token to the substrate after each successful registration:
27+
28+
```
29+
POST /api/v1/shopkeeper/devices
30+
Content-Type: application/json
31+
Authorization headers: <devise_token_auth headers>
32+
33+
{
34+
"device": {
35+
"token": "<FCM registration token>",
36+
"platform": "google",
37+
"bundle_id": "com.your.application.id"
38+
}
39+
}
40+
```
41+
42+
- **`platform: "google"`** — matches Action Push Native's FCM service convention. Do **not** send `"android"`.
43+
- **`bundle_id`** — optional but recommended; helps disambiguate when one shopkeeper uses multiple agent-generated app variants. Use the Android `applicationId`.
44+
- Idempotent: the server upserts on `(platform, token)`. First POST returns `201 Created`, subsequent re-registrations of the same `(platform, token)` return `200 OK` and refresh `last_active_at`.
45+
- If the same token previously belonged to another shopkeeper (e.g. user signed out + new user signed in on the same device), the server rebinds it to the current shopkeeper.
46+
47+
On sign-out, the client should `DELETE /api/v1/shopkeeper/devices/:id` to unregister. The server returns `204 No Content`.
48+
49+
Full schemas: see `Device`, `DeviceAttributes`, `DeviceCreateRequest` in [`docs/openapi.yaml`](openapi.yaml) and the `/devices` paths near the end.
50+
51+
---
52+
53+
## Firebase project setup
54+
55+
In Firebase console:
56+
57+
1. Create a Firebase project for the substrate's canonical bundle ID (`com.nativeapptemplate.*`). Agent-renamed apps create their own Firebase projects.
58+
2. Add an Android app to the project; download `google-services.json`.
59+
3. Place `google-services.json` at `app/google-services.json` (gitignored — it contains the project's API key, which is not strictly secret but should rotate per fork).
60+
4. Generate a **service account key** (Project settings → Service accounts → Generate new private key). The JSON file goes into the API's encrypted credentials at `Rails.application.credentials.action_push_native.fcm.encryption_key` (see `config/push.yml`); the `project_id` lives there too.
61+
62+
Project-level `build.gradle.kts`:
63+
64+
```kotlin
65+
plugins {
66+
id("com.google.gms.google-services") version "4.4.2" apply false
67+
}
68+
```
69+
70+
App-level `build.gradle.kts`:
71+
72+
```kotlin
73+
plugins {
74+
id("com.android.application")
75+
id("com.google.gms.google-services")
76+
}
77+
78+
dependencies {
79+
implementation(platform("com.google.firebase:firebase-bom:33.4.0"))
80+
implementation("com.google.firebase:firebase-messaging-ktx")
81+
}
82+
```
83+
84+
The Rails server side is already wired up (PRs #59, #60, #61). The Android client is the missing piece.
85+
86+
---
87+
88+
## Manifest: permission, service, channel
89+
90+
`AndroidManifest.xml`:
91+
92+
```xml
93+
<!-- API 33+ requires runtime permission for notifications -->
94+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
95+
96+
<application ...>
97+
<!-- FCM token + payload receiver -->
98+
<service
99+
android:name=".push.AppFirebaseMessagingService"
100+
android:exported="false">
101+
<intent-filter>
102+
<action android:name="com.google.firebase.MESSAGING_EVENT" />
103+
</intent-filter>
104+
</service>
105+
106+
<!-- Optional: default notification channel + small icon -->
107+
<meta-data
108+
android:name="com.google.firebase.messaging.default_notification_channel_id"
109+
android:value="@string/default_notification_channel_id" />
110+
<meta-data
111+
android:name="com.google.firebase.messaging.default_notification_icon"
112+
android:resource="@drawable/ic_notification" />
113+
</application>
114+
```
115+
116+
---
117+
118+
## Permission UX (API 33+)
119+
120+
On Android 13+, `POST_NOTIFICATIONS` is a runtime permission. Request at a moment that makes sense — after sign-in and feature exposure, not at first launch.
121+
122+
```kotlin
123+
class PushPermissionRequester(private val activity: ComponentActivity) {
124+
private val launcher = activity.registerForActivityResult(
125+
ActivityResultContracts.RequestPermission()
126+
) { granted ->
127+
if (granted) registerForPush() else /* surface "open settings" affordance */
128+
}
129+
130+
fun ensurePermission() {
131+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
132+
registerForPush()
133+
return
134+
}
135+
when (PackageManager.PERMISSION_GRANTED) {
136+
ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) ->
137+
registerForPush()
138+
else ->
139+
launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
140+
}
141+
}
142+
}
143+
```
144+
145+
If permission is denied, the FCM token still arrives via the service callback, but the system suppresses any UI. Decide whether to skip POSTing the token in that case (saves the round-trip) or POST it anyway (so the next permission grant Just Works without re-registration).
146+
147+
---
148+
149+
## Notification channels
150+
151+
Required on API 26+. Create channels at app startup (idempotent):
152+
153+
```kotlin
154+
class NotificationChannels(private val context: Context) {
155+
fun ensure() {
156+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
157+
val nm = context.getSystemService(NotificationManager::class.java)
158+
val channel = NotificationChannel(
159+
DEFAULT_CHANNEL_ID,
160+
context.getString(R.string.default_channel_name),
161+
NotificationManager.IMPORTANCE_DEFAULT
162+
).apply {
163+
description = context.getString(R.string.default_channel_description)
164+
}
165+
nm.createNotificationChannel(channel)
166+
}
167+
168+
companion object {
169+
const val DEFAULT_CHANNEL_ID = "item_tag_completed"
170+
}
171+
}
172+
```
173+
174+
Reference `DEFAULT_CHANNEL_ID` from the manifest's `default_notification_channel_id` `meta-data` so FCM's "notification" payloads land in this channel without per-message channel config.
175+
176+
---
177+
178+
## Token lifecycle
179+
180+
### 1. Token received (`FirebaseMessagingService`)
181+
182+
```kotlin
183+
class AppFirebaseMessagingService : FirebaseMessagingService() {
184+
override fun onNewToken(token: String) {
185+
// Fires on first launch and whenever FCM rotates the token.
186+
// Posting is best-effort; if not signed in, queue and retry on sign-in.
187+
PushTokenSync.register(token)
188+
}
189+
190+
override fun onMessageReceived(message: RemoteMessage) {
191+
// Foreground delivery handler — see "Foreground & background" below.
192+
NotificationPresenter.show(this, message)
193+
}
194+
}
195+
```
196+
197+
`PushTokenSync.register(token)` posts to `/api/v1/shopkeeper/devices` with `platform: "google"`. Cache the returned `device.id` in EncryptedSharedPreferences — you'll need it for the sign-out DELETE.
198+
199+
### 2. Token refresh
200+
201+
FCM may rotate the token at any time; `onNewToken` fires automatically. No need to poll. Optionally, on app startup *while signed in*, fetch the current token via `FirebaseMessaging.getInstance().token` and POST again — defends against the rare case where `onNewToken` was missed (e.g. user reinstalled the app).
202+
203+
### 3. Sign-in (existing token, new shopkeeper)
204+
205+
When a different user signs in on the same device, fetch the cached token and re-POST. The server rebinds the token to the new `current_shopkeeper` (single round-trip, atomic).
206+
207+
### 4. Sign-out
208+
209+
```kotlin
210+
suspend fun signOut() {
211+
val deviceId = encryptedPrefs.getString(KEY_DEVICE_ID, null)
212+
if (deviceId != null) {
213+
runCatching { api.deleteDevice(deviceId) }
214+
}
215+
encryptedPrefs.edit { remove(KEY_DEVICE_ID) }
216+
// ...rest of sign-out flow
217+
}
218+
```
219+
220+
If the DELETE fails (network), don't block sign-out. The server's `last_active_at` staleness scope (90 days) eventually prunes orphaned tokens.
221+
222+
Optionally also call `FirebaseMessaging.getInstance().deleteToken()` to fully invalidate the FCM token. Skip this if you want re-sign-in on the same device to be silent (no permission re-prompt, immediate token reuse).
223+
224+
---
225+
226+
## Foreground & background presentation
227+
228+
The notifier (`ItemTagNotifier` in this repo) sends `title`, `body`, and `data: { url: <api path> }` via Action Push Native, which constructs the FCM payload as:
229+
230+
- **Notification payload** (system shows automatically when app is backgrounded): `title`, `body`
231+
- **Data payload**: `url` (and any other `data:` keys)
232+
233+
When the app is in the **foreground**, `onMessageReceived` is called with the `RemoteMessage` and the system does **not** show a notification automatically — the client must build it:
234+
235+
```kotlin
236+
object NotificationPresenter {
237+
fun show(context: Context, message: RemoteMessage) {
238+
val title = message.notification?.title ?: message.data["title"] ?: return
239+
val body = message.notification?.body ?: message.data["body"] ?: ""
240+
val url = message.data["url"]
241+
242+
val intent = url?.let { DeepLinkRouter.intentFor(context, it) }
243+
?: Intent(context, MainActivity::class.java)
244+
val pending = PendingIntent.getActivity(
245+
context, 0, intent,
246+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
247+
)
248+
249+
val notification = NotificationCompat.Builder(context, NotificationChannels.DEFAULT_CHANNEL_ID)
250+
.setContentTitle(title)
251+
.setContentText(body)
252+
.setSmallIcon(R.drawable.ic_notification)
253+
.setContentIntent(pending)
254+
.setAutoCancel(true)
255+
.build()
256+
257+
NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification)
258+
}
259+
}
260+
```
261+
262+
When the app is in the **background or killed**, the system shows the notification automatically using the FCM `notification` payload; tapping it launches the activity declared in `getLaunchIntentForPackage` with the `data` keys as extras. Pull `url` from the launching `Intent` in `MainActivity#onCreate` / `onNewIntent` and route.
263+
264+
The `data.url` is an API path (e.g. `/api/v1/shopkeeper/shops/:shop_id/item_tags/:id`) — the client maps it to its own navigation stack.
265+
266+
---
267+
268+
## Testing locally
269+
270+
- **Triggering a push**: in the Rails console on a dev box with credentials provisioned, transition `ItemTag` from `idled` to `completed`:
271+
```ruby
272+
it = ItemTag.find(...)
273+
it.completed_by = some_other_shopkeeper # not the recipient
274+
it.complete!
275+
```
276+
The recipient set is `it.shop.account.shopkeepers` minus `completed_by`. See `app/models/item_tag.rb#notify_completed`.
277+
- **Without provider credentials**: `ApplicationPushNotification.enabled` defaults to `!Rails.env.test?`. In dev/prod without credentials, delivery enqueues but fails at the FCM HTTP call. Watch the job logs.
278+
- **Without a real device**: emulators with Google Play Services receive FCM pushes fine. AVD images **without** Google Play (the AOSP images) cannot.
279+
280+
---
281+
282+
## Out of scope for this guide
283+
284+
- **Notification grouping / summaries**: tune via `setGroup` if needed; not required for v1.
285+
- **Custom notification actions**: defer.
286+
- **In-app messaging** / Firebase Remote Config / Analytics: separate Firebase products; not part of push.
287+
- **Topic subscriptions** (`subscribeToTopic`): not used — push is per-recipient via the substrate's `Noticed::Notification` records, not topic-based.

0 commit comments

Comments
 (0)