@maccesar/titools 4.22.0 → 4.23.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maccesar/titools",
3
- "version": "4.22.0",
3
+ "version": "4.23.1",
4
4
  "description": "Titanium SDK skills and agents for AI coding assistants, including native game and sound synthesis modules",
5
5
  "main": "lib/index.js",
6
6
  "type": "module",
@@ -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.0)
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 you edit the Android theme / FCM meta-data by hand.
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-*/ic_stat_notify.png × 5
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-*/ic_stat_notify.png` × 5 | — | `--notification-icon` |
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
- Only three pieces are opt-in, and for one reason: they produce nothing useful until you edit XML by hand. `splash_icon.png` is inert without `windowSplashScreenAnimatedIcon` in the theme, and `ic_stat_notify.png` is inert without the FCM `meta-data` entry.
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 you edit the Android theme / FCM meta-data by hand.
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 `ic_stat_notify.png × 5`. |
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 you edit the Android theme / FCM meta-data by hand.
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-*/ic_stat_notify.png × 5
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.
@@ -11,7 +11,6 @@ This file is about the community module [`firebase.cloudmessaging`](https://gith
11
11
  <!-- TOC-START -->
12
12
  ## Contents
13
13
 
14
- - [Contents](#contents)
15
14
  - [1. The four doors](#1-the-four-doors)
16
15
  - [2. The payload decides how much of the module runs](#2-the-payload-decides-how-much-of-the-module-runs)
17
16
  - [3. A tap does not fire `didReceiveMessage`](#3-a-tap-does-not-fire-didreceivemessage)
@@ -34,7 +33,7 @@ This file is about the community module [`firebase.cloudmessaging`](https://gith
34
33
  | Backgrounded, person taps the notification | `Ti.App` `resumed`, then read the Intent | nothing |
35
34
  | **Already in front**, person taps a notification sitting in the tray | `newintent` on the root activity | `launchMode="singleTop"` |
36
35
 
37
- A module that fires `didOpenNotification` replaces rows two to four with one listener. See [If your module fires `didOpenNotification`](#if-your-module-fires-didopennotification).
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).
38
37
 
39
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.
40
39
 
@@ -80,6 +79,26 @@ if (OS_ANDROID) {
80
79
 
81
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)`.
82
81
 
82
+ ### Registering on Android: two calls that go quiet
83
+
84
+ The channel has to exist and be handed to the module **before** asking for the token, and its id has to match the `default_notification_channel_id` meta-data in `tiapp.xml`. Mismatch them and the notification is posted against a channel nobody declared.
85
+
86
+ ```javascript
87
+ modulo.notificationChannel = Ti.Android.NotificationManager.createNotificationChannel({
88
+ id: 'default_channel', // the same id tiapp.xml declares
89
+ name: L('push_channel_name'),
90
+ importance: Ti.Android.IMPORTANCE_DEFAULT
91
+ })
92
+
93
+ modulo.registerForPushNotifications()
94
+
95
+ // Firebase does not announce a token it already had, so didRefreshRegistrationToken
96
+ // may never fire on a device that registered before. Ask for it once, a moment later.
97
+ setTimeout(() => guardarToken(modulo.fcmToken), 4000)
98
+ ```
99
+
100
+ That last line is the one that bites. On a fresh install the event fires and everything looks fine; on a device that already had a token from an earlier run it never fires, the app never sends the token to the server, and every push to that device is addressed to nobody. Make `guardarToken` idempotent — it will be called more than once with the same value.
101
+
83
102
  ## 2. The payload decides how much of the module runs
84
103
 
85
104
  Android distinguishes a *notification message* from a *data message*, and the difference is not cosmetic.
@@ -93,6 +112,12 @@ That last symptom is worth naming because it sends people down the wrong road. I
93
112
 
94
113
  Firebase's own server documentation teaches the `notification` block, which is why this is such a common shape to arrive at.
95
114
 
115
+ ### The icon, whichever route posts it
116
+
117
+ 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.
118
+
119
+ 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.
120
+
96
121
  ## 3. A tap does not fire `didReceiveMessage`
97
122
 
98
123
  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`.
@@ -134,20 +159,54 @@ Take the activity name from the built manifest rather than guessing it: `build/a
134
159
 
135
160
  ### If your module fires `didOpenNotification`
136
161
 
137
- Everything above is what the published module needs. A module that fires `didOpenNotification` from `setNotificationData()` makes all of it unnecessary: no `singleTop`, no `newintent`, no reading the Intent on resume. One listener covers every tap, whatever the app was doing.
162
+ 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:
163
+
164
+ | What the app was doing | Where the payload arrives |
165
+ | --- | --- |
166
+ | Open, in front, message arrives | `didReceiveMessage` |
167
+ | Backgrounded, person taps | `didOpenNotification` |
168
+ | Already in front, person taps a notification in the tray | `didOpenNotification` |
169
+ | Killed, person taps | `fcm_data` extra on the launch Intent |
170
+
171
+ 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.
138
172
 
139
173
  ```javascript
140
- modulo.addEventListener('didOpenNotification', (e) => {
141
- abrirPanel((e.message && e.message.data) || {})
142
- })
174
+ if (OS_ANDROID) {
175
+ // With the runtime alive the launcher Intent is not delivered a second time,
176
+ // so this event is the only way in.
177
+ modulo.addEventListener('didOpenNotification', (e) => {
178
+ abrirPanel((e.message && e.message.data) || {})
179
+ })
180
+
181
+ // On a cold start the module does not exist yet when the notification is
182
+ // opened, so the payload travels in the Intent. This is that route.
183
+ const desdeElIntent = (origen) => {
184
+ const contenido = Ti.Android.rootActivity.intent.getStringExtra('fcm_data')
185
+
186
+ if (!contenido) {
187
+ log.debug(`Nothing in the Intent (${origen})`)
188
+ return
189
+ }
190
+
191
+ modulo.clearLastData()
192
+ abrirPanel(JSON.parse(contenido))
193
+ }
194
+
195
+ Ti.App.addEventListener('resumed', () => desdeElIntent('resumed'))
196
+ desdeElIntent('arranque')
197
+ }
143
198
  ```
144
199
 
200
+ `resumed` stays even though the event covers the warm tap: it is what catches an Intent the module left behind on a version that still writes one. When the event delivered the payload the Intent is empty and the handler returns without opening anything, which is the exclusivity the section below describes.
201
+
145
202
  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.
146
203
 
147
204
  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.
148
205
 
149
206
  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.
150
207
 
208
+ 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.
209
+
151
210
  ## 5. Why two splash screens flash by
152
211
 
153
212
  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.
@@ -233,7 +292,15 @@ Verified case by case on an iPad. The third row is the one that does not work on
233
292
 
234
293
  ```javascript
235
294
  Ti.Network.registerForPushNotifications({
236
- success: (e) => { enviarTokenAlServidor(e.deviceToken) },
295
+ success: (e) => {
296
+ // e.deviceToken is the APNs one. It is not what you send to a server that
297
+ // pushes through FCM, and Firebase picks it up on its own — see below.
298
+ log.info(`APNs token: ${e.deviceToken ? 'received' : 'EMPTY'}`)
299
+
300
+ modulo.fetchToken((r) => {
301
+ if (r && r.token) { guardarToken(r.token) }
302
+ })
303
+ },
237
304
  error: (e) => { log.error(e.error) },
238
305
  callback: (e) => {
239
306
  // Foreground, background and closed all arrive here.
@@ -258,6 +325,16 @@ if (OS_IOS) {
258
325
  }
259
326
  ```
260
327
 
328
+ **The token comes from `fetchToken`, not from the property.** Right after APNs answers, `modulo.fcmToken` is empty: Firebase still has to exchange the APNs token for its own, and that exchange has no fixed duration. Waiting does not fix it — four seconds was still empty on a device. `fetchToken` calls back when the token exists, which is the only way to know.
329
+
330
+ ```javascript
331
+ // The apnsToken is never assigned by hand. Firebase's swizzling does it, and
332
+ // setting it yourself produces BadDeviceToken when you send.
333
+ modulo.fetchToken((e) => {
334
+ if (e && e.token) { guardarToken(e.token) }
335
+ })
336
+ ```
337
+
261
338
  **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:
262
339
 
263
340
  ```php
@@ -297,6 +374,7 @@ Do not test with the app added to the battery whitelist. That measures a phone y
297
374
  | --- | --- | --- |
298
375
  | Tapping restarts the app from zero and loses state | Top-level `notification` block; the system owns the intent | Section 2 |
299
376
  | `image`, `big_text`, `channelId`, `sound`, `vibrate` ignored | Same | Section 2 |
377
+ | A white square or the launcher icon in the status bar | No `notificationicon` drawable; the module fell back to `appicon` | Section 2 |
300
378
  | Foreground and cold start work, backgrounded tap does nothing | Nobody reads the Intent on resume | Section 3 |
301
379
  | Nothing at all happens when tapping a stored notification while using the app | No `resumed`, no `newintent` listener, or no `singleTop` | Section 4 |
302
380
  | Blank pages slide past before the app appears | `PushHandlerActivity` has no theme; Titanium spawns a duplicate root activity | Section 5 |
@@ -306,3 +384,6 @@ Do not test with the app added to the battery whitelist. That measures a phone y
306
384
  | iOS registers but never gets a token | Firebase loaded after APNs answered | Section 8 |
307
385
  | iOS receives the push and does nothing when tapped | Empty `callback` | Section 8 |
308
386
  | Nothing arrives for twenty minutes, then five at once | OEM battery management | Section 9 |
387
+ | Push works on a fresh install and never on a reinstall | `didRefreshRegistrationToken` does not fire for a token Firebase already had | Section 1 |
388
+ | iOS: `fcmToken` is empty right after the permission is granted | It is; the APNs-for-FCM exchange has no fixed duration. Use `fetchToken` | Section 8 |
389
+ | iOS: `BadDeviceToken` when sending | `apnsToken` was assigned by hand instead of leaving it to the swizzling | Section 8 |