@maccesar/titools 4.21.0 → 4.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/skills/purgetss/SKILL.md +1 -1
- package/skills/purgetss/references/app-branding.md +38 -4
- package/skills/purgetss/references/cli-commands.md +3 -2
- package/skills/purgetss/references/customization-deep-dive.md +3 -2
- package/skills/purgetss/references/version-history.md +4 -0
- package/skills/ti-expert/SKILL.md +3 -1
- package/skills/ti-expert/references/push-notifications.md +325 -0
- package/skills/ti-ui/references/icons-and-splash-screens.md +35 -0
package/README.md
CHANGED
|
@@ -160,7 +160,7 @@ Together they work like this:
|
|
|
160
160
|
| ------------------------------- | ------------------------------- | ----------------------------- |
|
|
161
161
|
| "Create a login screen" | Context about project structure | `ti-expert`, `ti-ui` |
|
|
162
162
|
| "Optimize ListView performance" | Points to docs location | `ti-ui` reads specific files |
|
|
163
|
-
| "Implement push notifications" | API reference paths | `ti-howtos`
|
|
163
|
+
| "Implement push notifications" | API reference paths | `ti-expert`, `ti-howtos` |
|
|
164
164
|
|
|
165
165
|
You do not need to explicitly invoke skills. The AI detects when to use them based on your question.
|
|
166
166
|
|
package/package.json
CHANGED
package/skills/purgetss/SKILL.md
CHANGED
|
@@ -468,7 +468,7 @@ Load these only when needed:
|
|
|
468
468
|
- [PurgeTSS UI in Classic](references/purgetss-ui-classic.md) - Native JavaScript setup, public exports, animation objects, cleanup, and platform notes
|
|
469
469
|
|
|
470
470
|
### Release Notes
|
|
471
|
-
- [Version History](references/version-history.md) - Release-by-release feature additions and behavior changes (v7.4.0 → v7.17.
|
|
471
|
+
- [Version History](references/version-history.md) - Release-by-release feature additions and behavior changes (v7.4.0 → v7.17.1)
|
|
472
472
|
|
|
473
473
|
> **💡 TEXT FONTS (Google Fonts, Roboto, etc.)**
|
|
474
474
|
> For text fonts, see [Custom Fonts](references/custom-fonts.md).
|
|
@@ -25,6 +25,7 @@ For the terse flag reference, see the [`brand` command reference](./cli-commands
|
|
|
25
25
|
- [Android dark mode](#android-dark-mode)
|
|
26
26
|
- [Android 12+ splash artwork](#android-12-splash-artwork)
|
|
27
27
|
- [Android pre-12 splash](#android-pre-12-splash)
|
|
28
|
+
- [FCM notification icon](#fcm-notification-icon)
|
|
28
29
|
- [The iOS launch screen and LaunchLogo.png](#the-ios-launch-screen-and-launchlogopng)
|
|
29
30
|
- [iPhone launch images](#iphone-launch-images)
|
|
30
31
|
- [iOS 18+ Dark and Tinted variants](#ios-18-dark-and-tinted-variants)
|
|
@@ -164,9 +165,10 @@ brand: {
|
|
|
164
165
|
appicon: { padding: '10%' }, // appicon.png (128×128)
|
|
165
166
|
androidSplash: { padding: '26%' }, // assets/android/default.png + images/res-*/default.png × 11
|
|
166
167
|
|
|
167
|
-
// Opt-in: inert until
|
|
168
|
+
// Opt-in: splash_icon stays inert until the Android theme points at it.
|
|
169
|
+
// notificationicon is read by firebase.cloudmessaging, by that exact name.
|
|
168
170
|
splashIcon: { enabled: false }, // drawable-*/splash_icon.png × 5
|
|
169
|
-
notificationIcon: { enabled: false }, // drawable-*/
|
|
171
|
+
notificationIcon: { enabled: false }, // drawable-*/notificationicon.png × 5
|
|
170
172
|
ninePatch: { enabled: false } // background.9.png (not implemented yet)
|
|
171
173
|
}
|
|
172
174
|
```
|
|
@@ -258,12 +260,12 @@ And these live at the top level:
|
|
|
258
260
|
| `appicon` | `appicon` | `appicon.png` (128×128) | `10%` | yes |
|
|
259
261
|
| `android-splash` | `androidSplash` | `assets/android/default.png` + `images/res-*/default.png` × 11 | `26%` | yes |
|
|
260
262
|
| `splash-icon` | `splashIcon` | `drawable-*/splash_icon.png` × 5 | — | `--splash-icon` |
|
|
261
|
-
| `notification-icon` | `notificationIcon` | `drawable-*/
|
|
263
|
+
| `notification-icon` | `notificationIcon` | `drawable-*/notificationicon.png` × 5 | — | `--notification-icon` |
|
|
262
264
|
| `nine-patch` | `ninePatch` | `background.9.png` | — | `--nine-patch` (not implemented yet) |
|
|
263
265
|
|
|
264
266
|
`ic_launcher.xml` always travels inside `adaptive`; it is never generated on its own.
|
|
265
267
|
|
|
266
|
-
|
|
268
|
+
Three pieces are opt-in, because none of them does anything on its own. `splash_icon.png` is inert until the theme points `windowSplashScreenAnimatedIcon` at it. `notificationicon.png` needs `firebase.cloudmessaging` in the project, plus a `meta-data` entry for the notification messages that do not reach it by name — see [FCM notification icon](#fcm-notification-icon). `background.9.png` is not implemented yet.
|
|
267
269
|
|
|
268
270
|
### `background` is inherited, `padding` is not
|
|
269
271
|
|
|
@@ -497,6 +499,34 @@ Earlier versions regenerated only the first file and hid the other 11 behind a `
|
|
|
497
499
|
> A solid `windowBackground` wins
|
|
498
500
|
> If the launch theme sets `android:windowBackground` to a plain color — which is what [Matching the launch background](#matching-the-launch-background) recommends — that color takes precedence over this artwork on Android <12. Drop the `windowBackground` item if you want the image to show instead.
|
|
499
501
|
|
|
502
|
+
## FCM notification icon
|
|
503
|
+
|
|
504
|
+
`notification-icon` is off by default. Turn it on with `brand.notificationIcon.enabled`, or for a single run with `--notification-icon`. It writes five densities to `app/platform/android/res/drawable-*/notificationicon.png` (`platform/android/res/drawable-*/notificationicon.png` in Classic), white on transparent, because **Android tints status-bar icons at runtime**: every non-transparent pixel comes out white and the color in the artwork is discarded.
|
|
505
|
+
|
|
506
|
+
The filename is fixed, like `appicon.png` and `DefaultIcon.png`, because the consumer dictates it. `firebase.cloudmessaging` resolves the drawable by name inside `TiFirebaseMessagingService.showNotification()`, through `getResource("notificationicon")`, and that lookup is the only path a **data** message has. When the drawable is missing the module falls back to `appicon` — the opaque launcher icon, which the status bar renders as a white blob, since only the alpha channel survives the tint.
|
|
507
|
+
|
|
508
|
+
**Notification** messages take the configurable path instead. Two `meta-data` entries go under `<application>` in `tiapp.xml`:
|
|
509
|
+
|
|
510
|
+
```xml
|
|
511
|
+
<meta-data android:name="com.google.firebase.messaging.default_notification_icon"
|
|
512
|
+
android:resource="@drawable/notificationicon"/>
|
|
513
|
+
<meta-data android:name="com.google.firebase.messaging.default_notification_color"
|
|
514
|
+
android:resource="@color/notification_tint"/>
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
The second one colors the notification's accent, not the icon. Create or merge `app/platform/android/res/values/colors.xml` (`platform/android/res/values/colors.xml` in Classic):
|
|
518
|
+
|
|
519
|
+
```xml
|
|
520
|
+
<?xml version="1.0" encoding="utf-8"?>
|
|
521
|
+
<resources>
|
|
522
|
+
<color name="notification_tint">#0B1326</color>
|
|
523
|
+
</resources>
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
> **Renamed in v7.17.1**
|
|
527
|
+
>
|
|
528
|
+
> The piece used to write `ic_stat_notify.png`, the Android convention for status-bar drawables but not the name the module looks up. Under the old name a data message never found the icon, whatever the `meta-data` said. A project that wired `@drawable/ic_stat_notify` by hand changes that one line and deletes the five stale files.
|
|
529
|
+
|
|
500
530
|
## The iOS launch screen and LaunchLogo.png
|
|
501
531
|
|
|
502
532
|
Titanium builds `LaunchLogo.imageset` itself on every iOS build, resizing one source into the five sizes it needs. It looks for `LaunchLogo.png` first and falls back to `DefaultIcon.png`.
|
|
@@ -744,6 +774,10 @@ cp docs/my-logo-mono.svg purgetss/brand/logo-mono.svg
|
|
|
744
774
|
purgetss brand
|
|
745
775
|
```
|
|
746
776
|
|
|
777
|
+
### The notification shows a white square, or the app icon instead of my notification icon
|
|
778
|
+
|
|
779
|
+
`firebase.cloudmessaging` did not find the drawable and fell back to `appicon`, which the status bar tints into a solid blob. Three causes, in order of likelihood: the piece was never generated (`brand.notificationIcon.enabled` is `false` by default — run `purgetss brand --only notification-icon`), the project still carries `ic_stat_notify.png` from before v7.17.1, or the `meta-data` entry points at the old name. See [FCM notification icon](#fcm-notification-icon).
|
|
780
|
+
|
|
747
781
|
### iOS rejects the app icon upload ("contains transparency")
|
|
748
782
|
|
|
749
783
|
Apple requires App Store icons to have no alpha channel. `DefaultIcon-ios.png` is always flattened on `brand.background` for that reason. If you edited the file manually and reintroduced alpha, re-run `purgetss brand`.
|
|
@@ -82,7 +82,8 @@ module.exports = {
|
|
|
82
82
|
adaptive: { padding: '18%' }, legacyIcon: { padding: '10%' },
|
|
83
83
|
appicon: { padding: '10%' }, androidSplash: { padding: '26%' },
|
|
84
84
|
|
|
85
|
-
// Opt-in: inert until
|
|
85
|
+
// Opt-in: splash_icon stays inert until the Android theme points at it.
|
|
86
|
+
// notificationicon is read by firebase.cloudmessaging, by that exact name.
|
|
86
87
|
splashIcon: { enabled: false }, notificationIcon: { enabled: false },
|
|
87
88
|
ninePatch: { enabled: false }
|
|
88
89
|
},
|
|
@@ -236,7 +237,7 @@ Radius precedence and the outputs intentionally left unmasked are documented in
|
|
|
236
237
|
|
|
237
238
|
| Flag | Purpose |
|
|
238
239
|
| --- | --- |
|
|
239
|
-
| `--notification-icon` | Also emit `
|
|
240
|
+
| `--notification-icon` | Also emit `notificationicon.png × 5`. |
|
|
240
241
|
| `--splash-icon` | Also emit `splash_icon.png × 5`. |
|
|
241
242
|
| `--nine-patch` | Declared but not implemented yet; prints a warning and writes nothing. |
|
|
242
243
|
**Logo variants & overrides**
|
|
@@ -70,9 +70,10 @@ module.exports = {
|
|
|
70
70
|
appicon: { padding: '10%' }, // appicon.png (128×128)
|
|
71
71
|
androidSplash: { padding: '26%' }, // assets/android/default.png + images/res-*/default.png × 11
|
|
72
72
|
|
|
73
|
-
// Opt-in: inert until
|
|
73
|
+
// Opt-in: splash_icon stays inert until the Android theme points at it.
|
|
74
|
+
// notificationicon is read by firebase.cloudmessaging, by that exact name.
|
|
74
75
|
splashIcon: { enabled: false }, // drawable-*/splash_icon.png × 5
|
|
75
|
-
notificationIcon: { enabled: false }, // drawable-*/
|
|
76
|
+
notificationIcon: { enabled: false }, // drawable-*/notificationicon.png × 5
|
|
76
77
|
ninePatch: { enabled: false } // background.9.png (not implemented yet)
|
|
77
78
|
},
|
|
78
79
|
// Sources in purgetss/images/ are 4x masters: a 1024px file yields
|
|
@@ -8,6 +8,10 @@ When in doubt about whether a class, flag, or config key exists in the user's in
|
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
+
## v7.17.1
|
|
12
|
+
- **The `notification-icon` piece writes `notificationicon.png`, not `ic_stat_notify.png`.** `firebase.cloudmessaging` hardcodes that name — `TiFirebaseMessagingService.showNotification()` calls `getResource("notificationicon")` — and it is the only path a **data** message has. The `default_notification_icon` meta-data is the configurable one and covers **notification** messages alone, so under the old name no manifest entry could rescue a data message: the module fell back to the opaque `appicon`, which the status bar renders as a white blob. The filename is fixed rather than a config key, for the same reason `appicon.png` and `DefaultIcon.png` are. Projects that wired `@drawable/ic_stat_notify` by hand need to update that one meta-data line and delete the five stale files. See [app-branding.md](app-branding.md).
|
|
13
|
+
- The comment above the opt-in pieces in the generated config now names each consumer instead of claiming both are inert without hand-edited XML. Only `splash_icon` is.
|
|
14
|
+
|
|
11
15
|
## v7.17.0
|
|
12
16
|
- The `images:` section rejects unknown keys instead of ignoring them, at both levels: the five top-level keys (`quality`, `format`, `autoSync`, `confirmOverwrites`, `files`) and the three inside each `files[]` entry (`filename`, `width`, `height`, with `filename` required). Nothing is generated when validation fails. Never suggest `width`, `opacity`, `padding` or `output` as config keys — they are CLI flags and now abort the run. See [multi-density-images.md](multi-density-images.md).
|
|
13
17
|
- The generated `images:` block gained four comment lines explaining the 4× master convention, and the `quality` comment now names the formats it reaches: `webp`, `jpeg`, `avif`, `tiff`. PNG uses `compressionLevel: 9` and GIF takes no quality parameter, so neither is affected. Both copies changed — the one patched into an existing config and the one `init` seeds.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ti-expert
|
|
3
|
-
description: "Use when designing, reviewing, auditing, or analyzing Titanium SDK architecture and implementation (Alloy or Classic) — creating controllers/views/services, choosing models vs collections, deciding between app-owned and system UI, designing reusable Widgets or feedback/modality patterns, hunting memory leaks and cleanup gaps, testing, migrating legacy apps, building adaptive layouts for tablets, foldables and large screens, or moving documents in and out of the app: custom file types, UTIs, CFBundleDocumentTypes, Android intent filters, AirDrop/Quick Share, document icons, sharing a link or file via the iOS share sheet or Android intent chooser. AUTO-DETECT: If tiapp.xml exists, invoke BEFORE architectural decisions, new controllers/views, feedback components, or restructuring. Titanium navigation, memory, event, and modal patterns differ from web frameworks."
|
|
3
|
+
description: "Use when designing, reviewing, auditing, or analyzing Titanium SDK architecture and implementation (Alloy or Classic) — creating controllers/views/services, choosing models vs collections, deciding between app-owned and system UI, designing reusable Widgets or feedback/modality patterns, hunting memory leaks and cleanup gaps, testing, migrating legacy apps, building adaptive layouts for tablets, foldables and large screens, or moving documents in and out of the app: custom file types, UTIs, CFBundleDocumentTypes, Android intent filters, AirDrop/Quick Share, document icons, sharing a link or file via the iOS share sheet or Android intent chooser, or push notifications: FCM, a tap that opens nothing, an app that restarts when tapped. AUTO-DETECT: If tiapp.xml exists, invoke BEFORE architectural decisions, new controllers/views, feedback components, or restructuring. Titanium navigation, memory, event, and modal patterns differ from web frameworks."
|
|
4
4
|
argument-hint: "[architecture-topic]"
|
|
5
5
|
allowed-tools: Read, Grep, Glob, Edit, Write, Bash(git *), Bash(node *)
|
|
6
6
|
---
|
|
@@ -26,6 +26,7 @@ This file is an index. Read the references that govern the task before making a
|
|
|
26
26
|
| Tablet, foldable, large screen, or responsive behavior | [Adaptive layouts](references/adaptive-layouts.md) |
|
|
27
27
|
| Opening or receiving files: custom extensions, UTIs, intent filters, AirDrop, Quick Share, document icons | [File type association](references/file-type-association.md) |
|
|
28
28
|
| Sending content out: share sheet, intent chooser, sharing a link or a file, `dk.napp.social`, `com.alcoapps.socialshare` | [Sharing content out](references/sharing.md) |
|
|
29
|
+
| Push notifications: FCM, `firebase.cloudmessaging`, a tap that opens nothing, an app that restarts on tap, the payload to send | [Push notifications](references/push-notifications.md) |
|
|
29
30
|
| Security architecture | [Security fundamentals](references/security-fundamentals.md), [Device security](references/security-device.md) |
|
|
30
31
|
| Testing or performance | Load the matching guide under Quality & Performance below |
|
|
31
32
|
|
|
@@ -185,6 +186,7 @@ For the complete reference with examples, see [Alloy builtins and globals](refer
|
|
|
185
186
|
- [Controller Patterns](references/controller-patterns.md)
|
|
186
187
|
- [File Type Association](references/file-type-association.md) (custom extensions and UTIs, `CFBundleDocumentTypes`, Android intent filters, receiving files from AirDrop/Quick Share, document icons)
|
|
187
188
|
- [Sharing Content Out](references/sharing.md) (share sheet and intent chooser, why iOS still needs `dk.napp.social` and Android does not, text versus file, iPad popovers)
|
|
189
|
+
- [Push Notifications](references/push-notifications.md) (the four doors a push arrives through on Android, why a tap does not fire `didReceiveMessage`, `singleTop` and `newintent`, data-only versus `notification` block, the HTTP v1 payload)
|
|
188
190
|
- [Feedback Surfaces & App-Owned UI](references/feedback-surfaces.md)
|
|
189
191
|
- [Feedback Widget Contracts](references/feedback-widget-contracts.md)
|
|
190
192
|
- [Feedback Migration](references/feedback-migration.md)
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# Push notifications: what actually reaches your code
|
|
2
|
+
|
|
3
|
+
Receiving a push in a Titanium app is not one path. On Android a notification can reach your JavaScript through four different doors depending on what the app was doing when it arrived and what the person did with it, and the official documentation describes one of them.
|
|
4
|
+
|
|
5
|
+
iOS has one door and it works in every state. That asymmetry matters when a bug report says "it works on my iPhone": the same app, the same payload and the same handler behave differently, and the difference is not in your code.
|
|
6
|
+
|
|
7
|
+
That is the fact that explains every "the notification arrives but nothing happens" report you will find. The push is delivered, nothing errors, and the feature simply does not run, because the code is listening at a door the message never came through.
|
|
8
|
+
|
|
9
|
+
This file is about the community module [`firebase.cloudmessaging`](https://github.com/hansemannn/titanium-firebase-cloud-messaging), which is how Titanium apps do FCM. `Ti.Network.registerForPushNotifications()` exists on Android too, but its job there is the `POST_NOTIFICATIONS` runtime permission, not the token: it never reads a `callback` and never returns a `deviceToken`. The FCM token comes from the module.
|
|
10
|
+
|
|
11
|
+
<!-- TOC-START -->
|
|
12
|
+
## Contents
|
|
13
|
+
|
|
14
|
+
- [1. The four doors](#1-the-four-doors)
|
|
15
|
+
- [2. The payload decides how much of the module runs](#2-the-payload-decides-how-much-of-the-module-runs)
|
|
16
|
+
- [3. A tap does not fire `didReceiveMessage`](#3-a-tap-does-not-fire-didreceivemessage)
|
|
17
|
+
- [4. The case nobody documents: tapping with the app in front](#4-the-case-nobody-documents-tapping-with-the-app-in-front)
|
|
18
|
+
- [5. Why two splash screens flash by](#5-why-two-splash-screens-flash-by)
|
|
19
|
+
- [6. Sending the message](#6-sending-the-message)
|
|
20
|
+
- [7. Every value inside `data` is a string](#7-every-value-inside-data-is-a-string)
|
|
21
|
+
- [8. iOS](#8-ios)
|
|
22
|
+
- [9. The device may sit on your push for twenty minutes](#9-the-device-may-sit-on-your-push-for-twenty-minutes)
|
|
23
|
+
- [10. Symptom to cause](#10-symptom-to-cause)
|
|
24
|
+
|
|
25
|
+
<!-- TOC-END -->
|
|
26
|
+
|
|
27
|
+
## 1. The four doors
|
|
28
|
+
|
|
29
|
+
| What the app was doing | Where the payload arrives | Requires |
|
|
30
|
+
| --- | --- | --- |
|
|
31
|
+
| Open, in front, message arrives | `didReceiveMessage` on the module | nothing |
|
|
32
|
+
| Closed, person taps the notification | `fcm_data` extra on the launch Intent | reading it at startup |
|
|
33
|
+
| Backgrounded, person taps the notification | `Ti.App` `resumed`, then read the Intent | nothing |
|
|
34
|
+
| **Already in front**, person taps a notification sitting in the tray | `newintent` on the root activity | `launchMode="singleTop"` |
|
|
35
|
+
|
|
36
|
+
A module that fires `didOpenNotification` replaces rows three and four with one listener. Row two still arrives in the Intent, because the module does not exist yet when the notification is opened. See [If your module fires `didOpenNotification`](#if-your-module-fires-didopennotification).
|
|
37
|
+
|
|
38
|
+
Verified case by case on a physical Android phone, reading a log line that named which door each message came through rather than watching the screen.
|
|
39
|
+
|
|
40
|
+
The first row is the only one the module's own example covers. The second and third are in its README under `### Android intent data`, a section that sits between the token example and "Sending push messages" with no explanation of why you would need it. The fourth is not documented anywhere.
|
|
41
|
+
|
|
42
|
+
A single handler serves all four:
|
|
43
|
+
|
|
44
|
+
```javascript
|
|
45
|
+
if (OS_ANDROID) {
|
|
46
|
+
const mostrarAviso = (intentNuevo, origen) => {
|
|
47
|
+
const intent = intentNuevo || Ti.Android.rootActivity.intent
|
|
48
|
+
const contenido = intent.getStringExtra('fcm_data')
|
|
49
|
+
|
|
50
|
+
if (!contenido) {
|
|
51
|
+
log.debug(`Sin aviso en el Intent (${origen})`)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let datos
|
|
56
|
+
try {
|
|
57
|
+
datos = JSON.parse(contenido)
|
|
58
|
+
} catch (error) {
|
|
59
|
+
modulo.clearLastData()
|
|
60
|
+
return
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Consume it, or the same payload fires again on the next resume.
|
|
64
|
+
modulo.clearLastData()
|
|
65
|
+
log.info(`Aviso abierto (${origen})`)
|
|
66
|
+
abrirPanel(datos)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Both events pass an argument of their own, so they are wrapped: only
|
|
70
|
+
// 'newintent' carries an Intent and only it should be forwarded.
|
|
71
|
+
Ti.App.addEventListener('resumed', () => mostrarAviso(null, 'resumed'))
|
|
72
|
+
Ti.Android.rootActivity.addEventListener('newintent', (e) => mostrarAviso(e.intent, 'newintent'))
|
|
73
|
+
|
|
74
|
+
mostrarAviso(null, 'arranque')
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Label the origin.** Not for tidiness: when someone reports that no panel appeared, the word in that log line tells you immediately which door was involved, and the four behave differently. Without it every path prints the same sentence and a whole afternoon goes into finding out which one ran.
|
|
79
|
+
|
|
80
|
+
**Wrap the listeners.** `Ti.App.addEventListener('resumed', mostrarAviso)` passes the event object as the first argument, and if the function's first parameter is an Intent, the code blows up inside `getStringExtra`. The same trap catches `Alloy.Events.on('algo', unaFuncionConParametros)`.
|
|
81
|
+
|
|
82
|
+
## 2. The payload decides how much of the module runs
|
|
83
|
+
|
|
84
|
+
Android distinguishes a *notification message* from a *data message*, and the difference is not cosmetic.
|
|
85
|
+
|
|
86
|
+
Send a top-level `notification` block with the app in the background and Android posts the notification itself. `onMessageReceived` is never called, so `showNotification()` never runs and `PushHandlerActivity` never runs. What that costs:
|
|
87
|
+
|
|
88
|
+
- `image`, `big_text`, `big_text_summary`, `icon`, `rounded_large_icon`, `force_show_in_foreground`, `id`, `color`, `vibrate`, `sound` and `badge` are all ignored.
|
|
89
|
+
- The tap intent belongs to the system, so tapping **launches the app from scratch** instead of resuming it. Any game in progress, any half-filled form, gone.
|
|
90
|
+
|
|
91
|
+
That last symptom is worth naming because it sends people down the wrong road. It looks like an activity or `launchMode` problem, and it is not: `launchMode="singleTask"` on the root activity makes it worse, and patching the module's `PushHandlerActivity` does nothing, because neither of them ever runs. The fix is on the sender: put the fields inside `data`.
|
|
92
|
+
|
|
93
|
+
Firebase's own server documentation teaches the `notification` block, which is why this is such a common shape to arrive at.
|
|
94
|
+
|
|
95
|
+
### The icon, whichever route posts it
|
|
96
|
+
|
|
97
|
+
Both routes need a drawable named `notificationicon`, because the module resolves it by that exact name: `TiFirebaseMessagingService.showNotification()` calls `getResource("notificationicon")`, and that lookup is the only path a data message has. When it is missing the module falls back to `appicon` — the opaque launcher icon, which the status bar tints into a white square, since only the alpha channel survives. A notification message takes the configurable path instead, the `com.google.firebase.messaging.default_notification_icon` meta-data, which is why the same project can show a correct icon on one route and a blob on the other.
|
|
98
|
+
|
|
99
|
+
PurgeTSS generates the five densities, white on transparent and under the right name, with `purgetss brand --only notification-icon` — off by default. Its `app-branding.md` § "FCM notification icon" carries both meta-data entries and the `colors.xml` resource that `default_notification_color` needs.
|
|
100
|
+
|
|
101
|
+
## 3. A tap does not fire `didReceiveMessage`
|
|
102
|
+
|
|
103
|
+
When the module posts the notification and the person taps it, `PushHandlerActivity` puts the payload on the launcher Intent as the `fcm_data` extra and starts the activity. It does not fire `didReceiveMessage`.
|
|
104
|
+
|
|
105
|
+
The only thing that turns that extra into the event is `parseBootIntent()`, and it is called from exactly one place: the end of the module's `registerForPushNotifications()`. So the tap behaves differently depending on the app's state:
|
|
106
|
+
|
|
107
|
+
- **App closed.** It starts, calls register, `parseBootIntent()` runs, `didReceiveMessage` fires.
|
|
108
|
+
- **App backgrounded.** It resumes, register is not called again, nothing fires. The payload sits on the Intent and nobody reads it.
|
|
109
|
+
|
|
110
|
+
Two paths out of three appear to work, which is what makes this so hard to see. Taps work from a cold start, foreground delivery works, and the background case fails silently — so the investigation starts on the server.
|
|
111
|
+
|
|
112
|
+
If you read the Intent yourself at startup, as in section 1, `parseBootIntent()` finds nothing afterwards because `clearLastData()` already removed the extra. Only one of them fires. That is fine, and it is why the log says `arranque` rather than `didReceiveMessage` on a cold start.
|
|
113
|
+
|
|
114
|
+
## 4. The case nobody documents: tapping with the app in front
|
|
115
|
+
|
|
116
|
+
The notification arrived earlier and stayed in the tray. Later the person opens the app and plays. Then they pull down the shade for something else, see your notification there, and tap it.
|
|
117
|
+
|
|
118
|
+
The app is already in the foreground. It never paused, so **`resumed` does not fire**. And the activity is not recreated, so nothing reads the Intent. Nothing happens at all.
|
|
119
|
+
|
|
120
|
+
This is not an edge case. It is the most ordinary way a stored notification gets opened.
|
|
121
|
+
|
|
122
|
+
It needs two things, and neither works without the other:
|
|
123
|
+
|
|
124
|
+
```xml
|
|
125
|
+
<!-- tiapp.xml, inside <application> -->
|
|
126
|
+
<activity android:name="com.yourdomain.yourapp.YourAppActivity"
|
|
127
|
+
android:launchMode="singleTop" tools:node="merge" />
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
```javascript
|
|
131
|
+
Ti.Android.rootActivity.addEventListener('newintent', (e) => mostrarAviso(e.intent, 'newintent'))
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Without `singleTop`, Android treats the launcher Intent as "bring the existing task to the front" and discards it, so `onNewIntent` never runs and `newintent` never fires. Without the listener, the Intent arrives and nobody reads it.
|
|
135
|
+
|
|
136
|
+
`singleTop` is not `singleTask`. `singleTask` clears the task stack and will restart your app from its first screen; `singleTop` only prevents a second instance when the activity is already on top, and leaves the stack alone. Confusing the two costs a debugging session.
|
|
137
|
+
|
|
138
|
+
Take the activity name from the built manifest rather than guessing it: `build/android/app/build/intermediates/merged_manifest/debug/processDebugMainManifest/AndroidManifest.xml`.
|
|
139
|
+
|
|
140
|
+
### If your module fires `didOpenNotification`
|
|
141
|
+
|
|
142
|
+
Everything above is what the published module needs. A module that fires `didOpenNotification` from `setNotificationData()` drops the `singleTop`, the `newintent` listener and the Intent read on resume. Two routes remain instead of four, and they are exclusive:
|
|
143
|
+
|
|
144
|
+
| What the app was doing | Where the payload arrives |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| Open, in front, message arrives | `didReceiveMessage` |
|
|
147
|
+
| Backgrounded, person taps | `didOpenNotification` |
|
|
148
|
+
| Already in front, person taps a notification in the tray | `didOpenNotification` |
|
|
149
|
+
| Killed, person taps | `fcm_data` extra on the launch Intent |
|
|
150
|
+
|
|
151
|
+
Verified on a physical Android 15 phone and an API 35 emulator, each row confirmed by a log line naming the route. The first three ran back to back on one process id with a game in progress, which survived; only the fourth starts a new process.
|
|
152
|
+
|
|
153
|
+
```javascript
|
|
154
|
+
modulo.addEventListener('didOpenNotification', (e) => {
|
|
155
|
+
abrirPanel((e.message && e.message.data) || {})
|
|
156
|
+
})
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The payload sits under `message.data`, the same place `didReceiveMessage` puts it on both its live and its cold-start path, so one accessor covers a tap and an arrival. The first version of the event wrapped it directly in `message`; if the module you have predates 3.6.0, read `e.message` instead, or log the event once and look.
|
|
160
|
+
|
|
161
|
+
The Intent is still how a cold start arrives, so keep that path. Check `timodule.xml` in your installed module for the event, or just add the listener and watch whether it fires: a module without it ignores the registration silently.
|
|
162
|
+
|
|
163
|
+
Do not run both. Where the module also puts the payload on the Intent, `resumed` finds it again on Titanium's second pass and the panel opens twice. That is a real symptom, not a hypothetical: it shows up on Android 10 and not on Android 15, because the two versions differ in whether the launcher Intent gets delivered to a task that already exists.
|
|
164
|
+
|
|
165
|
+
To test the fourth row, swipe the app out of Recents. `adb shell am kill` looks like the same thing and is not: it kills the process but Android keeps the activity's saved state, and on restore Titanium puts `tiLaunchIntentExtras` from the previous launch back over the new Intent, taking `fcm_data` with it. The `TiRootActivity` checkpoint tells you which one you got — `savedInstanceState: null` is a real cold start, anything else is a restore. `adb shell am force-stop` is worse: it leaves the app in the stopped state, where Android stops delivering FCM messages until someone launches it by hand.
|
|
166
|
+
|
|
167
|
+
## 5. Why two splash screens flash by
|
|
168
|
+
|
|
169
|
+
Tap a notification and you may see one or two blank pages slide past before the app appears, and the splash icon never finishes animating. Opening the same app from the launcher icon looks fine.
|
|
170
|
+
|
|
171
|
+
Two separate causes, and it is worth knowing which is which before trying to fix either.
|
|
172
|
+
|
|
173
|
+
**The module's own activity.** `PushHandlerActivity` is declared in the module's `timodule.xml` without `android:theme`, so it inherits the application theme and Android gives it a full window with an enter and an exit animation. The activity calls `finish()` on its first line and never shows anything.
|
|
174
|
+
|
|
175
|
+
**Titanium's resume mechanism.** `TiRootActivity.onCreate()` builds the only intent it accepts:
|
|
176
|
+
|
|
177
|
+
```java
|
|
178
|
+
// This is the only intent Titanium supports in order to simulate "singleTask" like resume behavior.
|
|
179
|
+
Intent mainIntent = Intent.makeMainActivity(getComponentName());
|
|
180
|
+
mainIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
181
|
+
mainIntent.addFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
|
|
182
|
+
mainIntent.setPackage(getPackageName());
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The module's relaunch intent does not match it, so Android creates a **second** root activity. That one recognises itself as a duplicate, forwards the intent to the live activity through `onNewIntent()` and finishes. It works — and it is in fact what delivers the payload — but the duplicate draws its own splash first.
|
|
186
|
+
|
|
187
|
+
The two are linked: the mismatch is what makes the delivery happen. "Fixing" the intent so it matches removes the second splash **and** the delivery, unless the module also fires an event for the tap. Verified by trying it: the extra window disappeared and the panel stopped appearing.
|
|
188
|
+
|
|
189
|
+
So this is not something an app can fix on its own. Do not spend an afternoon on `launchMode` or on splash themes looking for it.
|
|
190
|
+
|
|
191
|
+
## 6. Sending the message
|
|
192
|
+
|
|
193
|
+
The legacy FCM API, the one with `'to' => 'DEVICE_TOKEN'` and a server key, was deprecated on 20 June 2023 and shut down from 22 July 2024. Examples using it are still all over the web and they no longer deliver anything.
|
|
194
|
+
|
|
195
|
+
HTTP v1 needs an OAuth 2.0 access token instead of the static key, which is why examples use a library rather than a bare POST.
|
|
196
|
+
|
|
197
|
+
```php
|
|
198
|
+
<?php
|
|
199
|
+
|
|
200
|
+
require __DIR__ . '/vendor/autoload.php';
|
|
201
|
+
|
|
202
|
+
use Kreait\Firebase\Factory;
|
|
203
|
+
use Kreait\Firebase\Messaging\CloudMessage;
|
|
204
|
+
|
|
205
|
+
$factory = (new Factory)->withServiceAccount('/path/to/service-account.json');
|
|
206
|
+
$messaging = $factory->createMessaging();
|
|
207
|
+
|
|
208
|
+
$message = CloudMessage::new()
|
|
209
|
+
->withToken($token)
|
|
210
|
+
->withData([
|
|
211
|
+
'title' => 'Title',
|
|
212
|
+
'message' => 'Body',
|
|
213
|
+
'channelId' => 'default_channel',
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
$messaging->send($message);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
`composer require kreait/firebase-php`. Note `withToken()`: version 8.0 removed `CloudMessage::withTarget()` outright and deprecated `toToken()`, so older snippets fail on a fresh install.
|
|
220
|
+
|
|
221
|
+
`channelId` must match a channel your app created, or Android 8 and later drop the notification with nothing in the log to say so.
|
|
222
|
+
|
|
223
|
+
**Without Composer**, sign the service account JWT yourself with `openssl_sign`, exchange it for an access token at Google's token endpoint, and POST the same payload to `https://fcm.googleapis.com/v1/projects/<project-id>/messages:send`. It is about forty lines and it is the right answer when deployment does not carry `/vendor` to the server.
|
|
224
|
+
|
|
225
|
+
To reach every installed device without keeping token lists, send to a topic the app subscribes to at startup. Two requests cover a whole install base regardless of size, with no queue, no batching and no dead tokens to clean up.
|
|
226
|
+
|
|
227
|
+
## 7. Every value inside `data` is a string
|
|
228
|
+
|
|
229
|
+
FCM rejects anything else. Sending `['badge' => 3]` or `['vibrate' => true]` returns an `INVALID_ARGUMENT` naming the field, and Kreait throws before the request even leaves.
|
|
230
|
+
|
|
231
|
+
This collides with the module's documented field list, which describes `vibrate` as boolean, `badge` and `id` as int, and `rounded_large_icon` and `force_show_in_foreground` as Boolean. They travel as `"true"` and `"1"`. The module parses them back with `TiConvert.toBoolean` and `JSONObject.optBoolean`, both of which accept the string forms.
|
|
232
|
+
|
|
233
|
+
An `array_map('strval', ...)` over the whole payload before sending saves the round trip.
|
|
234
|
+
|
|
235
|
+
## 8. iOS
|
|
236
|
+
|
|
237
|
+
iOS has one door, and it works. Everything in section 1 is an Android problem.
|
|
238
|
+
|
|
239
|
+
`Ti.Network.registerForPushNotifications` takes a `callback`, and that callback fires whether the message arrives with the app in front, whether the person taps a notification from the tray, and whether the app was closed. There is no intent to read, no `launchMode` to declare, no second listener. The payload is in `e.data`, and `e.inBackground` says which it was.
|
|
240
|
+
|
|
241
|
+
| What the app was doing | Panel shown |
|
|
242
|
+
| --- | --- |
|
|
243
|
+
| Open, in front, message arrives | yes |
|
|
244
|
+
| Backgrounded, person taps | yes |
|
|
245
|
+
| Already in front, person taps a stored notification | yes |
|
|
246
|
+
| Closed, person taps | yes |
|
|
247
|
+
|
|
248
|
+
Verified case by case on an iPad. The third row is the one that does not work on Android without the extra listener, which is worth knowing when a bug report says "it works on my iPhone".
|
|
249
|
+
|
|
250
|
+
```javascript
|
|
251
|
+
Ti.Network.registerForPushNotifications({
|
|
252
|
+
success: (e) => { enviarTokenAlServidor(e.deviceToken) },
|
|
253
|
+
error: (e) => { log.error(e.error) },
|
|
254
|
+
callback: (e) => {
|
|
255
|
+
// Foreground, background and closed all arrive here.
|
|
256
|
+
const datos = (e && e.data) || {}
|
|
257
|
+
abrirPanel(datos)
|
|
258
|
+
}
|
|
259
|
+
})
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
The module's own `example/app.js` leaves that callback empty, so copying the example gives an app that registers correctly, receives the push, and does nothing when tapped.
|
|
263
|
+
|
|
264
|
+
**Firebase has to start before APNs answers.** The module's `_configure` registers it as an application delegate, and its `didRegisterForRemoteNotificationsWithDeviceToken` is what hands the APNs token to Firebase. Load the module later, from a service or from a window's `open` handler, and APNs has already answered: nobody picks that token up and Firebase never issues one of its own.
|
|
265
|
+
|
|
266
|
+
Nothing fails visibly. The permission is granted, APNs returns a 64-character token, and then `fetchToken` never calls back and `didRefreshRegistrationToken` never fires. There is nothing in any log pointing at load order.
|
|
267
|
+
|
|
268
|
+
```javascript
|
|
269
|
+
// alloy.js
|
|
270
|
+
if (OS_IOS) {
|
|
271
|
+
const FirebaseCore = require('firebase.core')
|
|
272
|
+
FirebaseCore.configure()
|
|
273
|
+
require('firebase.cloudmessaging')
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**A data-only message shows nothing on iOS.** Android reads the text from `data`; iOS reads it from the APNs alert. Send both, they do not interfere:
|
|
278
|
+
|
|
279
|
+
```php
|
|
280
|
+
'data' => ['title' => $titulo, 'message' => $cuerpo, 'channelId' => 'default_channel'],
|
|
281
|
+
'apns' => [
|
|
282
|
+
'headers' => ['apns-priority' => '10'],
|
|
283
|
+
'payload' => ['aps' => ['alert' => ['title' => $titulo, 'body' => $cuerpo], 'sound' => 'default']],
|
|
284
|
+
],
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Forgetting the `apns` block is a quiet failure: Android works, iOS receives the message and displays nothing, and the payload is identical in both.
|
|
288
|
+
|
|
289
|
+
**Images need a Notification Service Extension.** *Read, not run.* The module's `image` field is Android only: `showNotification()` downloads it and builds a `BigPictureStyle`. On iOS, Firebase documents `apns.fcm_options.image` together with `"mutable-content": 1` inside `aps`, and that path needs a Notification Service Extension in the app, a separate Xcode target with its own App ID and provisioning profile, which a module cannot ship.
|
|
290
|
+
|
|
291
|
+
An image inside your own UI is a different matter and works normally: it is just a URL in `data` that your controller hands to an `ImageView`.
|
|
292
|
+
|
|
293
|
+
**`defaultImage` is a placeholder, not a fallback.** An `ImageView` that declares `defaultImage` and never gets an `image` renders the default on Android and an empty box on iOS. Reproduced on a device, and fixed by assigning the local asset explicitly:
|
|
294
|
+
|
|
295
|
+
```javascript
|
|
296
|
+
// Not: if (args.imagen) { $.preview.applyProperties({ image: args.imagen }) }
|
|
297
|
+
$.preview.applyProperties({ image: args.imagen || LOGO })
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
The API reference calls `defaultImage` the image to show *while* the remote one loads, so leaning on it for "no image at all" asks more of it than it promises. The failure is invisible from Android, which is where this kind of notification usually gets tested.
|
|
301
|
+
|
|
302
|
+
## 9. The device may sit on your push for twenty minutes
|
|
303
|
+
|
|
304
|
+
Aggressive OEM battery management holds FCM messages for backgrounded apps and releases them in a batch when something wakes the connection. On a ColorOS device this shows up in logcat by name, `OplusHansManager freeze`, seconds after the app goes to the background.
|
|
305
|
+
|
|
306
|
+
The practical consequence is for testing, not for production: a message that has not arrived after ten seconds has not necessarily failed. Confirm arrival in the log before concluding anything, or you will spend an hour debugging a delivery that was simply queued. Five test messages arriving within 700 milliseconds of each other, after twenty minutes of silence, is what this looks like.
|
|
307
|
+
|
|
308
|
+
Do not test with the app added to the battery whitelist. That measures a phone your users do not have.
|
|
309
|
+
|
|
310
|
+
## 10. Symptom to cause
|
|
311
|
+
|
|
312
|
+
| Symptom | Cause | Where to look |
|
|
313
|
+
| --- | --- | --- |
|
|
314
|
+
| Tapping restarts the app from zero and loses state | Top-level `notification` block; the system owns the intent | Section 2 |
|
|
315
|
+
| `image`, `big_text`, `channelId`, `sound`, `vibrate` ignored | Same | Section 2 |
|
|
316
|
+
| A white square or the launcher icon in the status bar | No `notificationicon` drawable; the module fell back to `appicon` | Section 2 |
|
|
317
|
+
| Foreground and cold start work, backgrounded tap does nothing | Nobody reads the Intent on resume | Section 3 |
|
|
318
|
+
| Nothing at all happens when tapping a stored notification while using the app | No `resumed`, no `newintent` listener, or no `singleTop` | Section 4 |
|
|
319
|
+
| Blank pages slide past before the app appears | `PushHandlerActivity` has no theme; Titanium spawns a duplicate root activity | Section 5 |
|
|
320
|
+
| Nothing is delivered and no error comes back | Legacy FCM API, shut down in 2024 | Section 6 |
|
|
321
|
+
| `INVALID_ARGUMENT` naming a `data` field | A non-string value | Section 7 |
|
|
322
|
+
| Android shows the notification, iOS shows nothing | No `apns.payload.aps.alert` in the message | Section 8 |
|
|
323
|
+
| iOS registers but never gets a token | Firebase loaded after APNs answered | Section 8 |
|
|
324
|
+
| iOS receives the push and does nothing when tapped | Empty `callback` | Section 8 |
|
|
325
|
+
| Nothing arrives for twenty minutes, then five at once | OEM battery management | Section 9 |
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
- [9. Common issues](#9-common-issues)
|
|
15
15
|
- [11. iOS launch files (storyboards)](#11-ios-launch-files-storyboards)
|
|
16
16
|
- [Best practices](#best-practices)
|
|
17
|
+
- [Community-Discovered Patterns](#community-discovered-patterns)
|
|
17
18
|
|
|
18
19
|
<!-- TOC-END -->
|
|
19
20
|
|
|
@@ -401,3 +402,37 @@ iOS caches launch screen snapshots. If changes do not appear:
|
|
|
401
402
|
6. Square icons only.
|
|
402
403
|
7. Clean the project after icon changes.
|
|
403
404
|
8. Use adaptive icons for Android 8.0+.
|
|
405
|
+
|
|
406
|
+
## Community-Discovered Patterns
|
|
407
|
+
|
|
408
|
+
### Android 12+ splash: the theme's parent decides whether any of it applies
|
|
409
|
+
|
|
410
|
+
Verified against SDK 13.4.1.GA, reading the generated `build/android/app/src/main/res/values*/ti_styles.xml` and `res/values-v31/` inside `titanium-13.4.1.aar`.
|
|
411
|
+
|
|
412
|
+
Titanium writes `android:theme="@style/Theme.Titanium"` onto the launcher Activity it generates, and builds this chain, where the last link is whatever theme the app declared on `<application>`:
|
|
413
|
+
|
|
414
|
+
```text
|
|
415
|
+
Theme.Titanium
|
|
416
|
+
└── Base.Theme.Titanium.Splash
|
|
417
|
+
└── Theme.AppDerived
|
|
418
|
+
└── the app's <application> theme
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
An `<application>` theme therefore ends up an *ancestor* of `Theme.Titanium`, and loses every attribute the two links below it already set. On API 31+ those are `android:windowBackground` and `android:windowSplashScreenBackground`, so a splash color declared on `<application>` silently does nothing. A custom theme must inherit from `@style/Theme.Titanium` and be assigned to the launcher Activity, which is what section 6's `tiapp.xml` snippet does — but its companion style inherits from `Theme.MaterialComponents.NoActionBar`, which detaches from the chain and drops the app's own theme with it.
|
|
422
|
+
|
|
423
|
+
Three attributes, one color:
|
|
424
|
+
|
|
425
|
+
| Attribute | Consumer |
|
|
426
|
+
|---|---|
|
|
427
|
+
| `android:windowSplashScreenBackground` | The API 31+ system splash. |
|
|
428
|
+
| `android:windowBackground` | The native window between the system splash and Titanium's first Window. |
|
|
429
|
+
| `android:colorBackground` | `Base.Theme.Titanium.Splash` points `windowSplashScreenBackground` at `?android:attr/colorBackground` on API 31+, and the launch status and navigation bars read it. |
|
|
430
|
+
|
|
431
|
+
Two consequences worth knowing before choosing a value:
|
|
432
|
+
|
|
433
|
+
- **Setting only the first two leaves the inherited color showing.** `colorBackground` is the one that is easy to miss.
|
|
434
|
+
- **A flat color for `windowBackground` replaces `@drawable/titanium_splash_icon_background`**, the layer-list Titanium uses on API 31+. That drawable paints a 160 dp white circle behind the icon, which is invisible on a white background and obvious on any other. Replacing it with a plain color removes the circle and the icon along with it.
|
|
435
|
+
|
|
436
|
+
Section 6's troubleshooting entry says the background color is the only customizable part. `android:windowSplashScreenAnimatedIcon` replaces the icon too — it is an icon slot, not a full-screen image, so the color around it still comes from the attributes above.
|
|
437
|
+
|
|
438
|
+
The `purgetss` skill's [launch-background.md](../../purgetss/references/launch-background.md) carries the full version of this, including the interaction with the legacy `res-*/default.png` artwork, and `purgetss brand --notes` prints the setup with the project's own brand color already substituted.
|