@maccesar/titools 4.20.0 → 4.22.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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  </div>
8
8
 
9
- TiTools is a Titanium SDK toolkit for AI coding assistants. It ships 10 skills — 5 specialist skills (`ti-expert`, `purgetss`, `ti-ui`, `ti-game`, `ti-synthengine`) plus 5 documentation-mirror skills (`ti-api`, `ti-guides`, `ti-howtos`, `alloy-guides`, `alloy-howtos`) — a research agent, and reference files covering Titanium architecture, API reference, native how-tos, Alloy MVC, PurgeTSS styling and resource automation, UI/UX patterns, 2D games, and native sound synthesis.
9
+ TiTools is a Titanium SDK toolkit for AI coding assistants. It ships 11 skills — 6 specialist skills (`ti-reuse-first`, `ti-expert`, `purgetss`, `ti-ui`, `ti-game`, `ti-synthengine`) plus 5 documentation-mirror skills (`ti-api`, `ti-guides`, `ti-howtos`, `alloy-guides`, `alloy-howtos`) — a research agent, and reference files covering reuse and simplification, Titanium architecture, API reference, native how-tos, Alloy MVC, PurgeTSS styling and resource automation, UI/UX patterns, 2D games, and native sound synthesis.
10
10
 
11
11
  The reference files are maintained against official documentation whenever an official source exists, so the assistant can retrieve current framework behavior instead of guessing from generic training data.
12
12
 
@@ -36,7 +36,7 @@ The fastest way to get started with Claude Code. One command to add the marketpl
36
36
  ```
37
37
 
38
38
  What you get:
39
- - All 10 TiTools skills (`ti-expert`, `purgetss`, `ti-ui`, `ti-game`, `ti-synthengine`, `ti-api`, `ti-guides`, `ti-howtos`, `alloy-guides`, `alloy-howtos`)
39
+ - All 11 TiTools skills (`ti-reuse-first`, `ti-expert`, `purgetss`, `ti-ui`, `ti-game`, `ti-synthengine`, `ti-api`, `ti-guides`, `ti-howtos`, `alloy-guides`, `alloy-howtos`)
40
40
  - ti-pro research agent
41
41
  - Session hook that auto-detects Titanium projects (`tiapp.xml`, Alloy, PurgeTSS, `ti.game`, `ti.synthengine`)
42
42
  - Slash commands: `/ti-check`, `/ti-new-screen`, `/ti-audit`
@@ -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
 
@@ -174,6 +174,7 @@ The knowledge index is based on the latest Titanium SDK documentation. If your p
174
174
 
175
175
  | Skill | Purpose | Best For |
176
176
  | ------------ | ----------------------------------- | --------------------------------------- |
177
+ | ti-reuse-first | Reuse before writing new code | Whether it should exist and what already does it; duplication sweeps |
177
178
  | ti-expert | Architecture and implementation | Starting point for most tasks |
178
179
  | purgetss | Alloy styling + asset automation | Utility classes; Alloy/Classic branding, images, colors, fonts and modules |
179
180
  | ti-ui | UI/UX patterns | Complex layouts, ListViews, platform UI |
@@ -186,7 +187,8 @@ The knowledge index is based on the latest Titanium SDK documentation. If your p
186
187
  | alloy-howtos | Alloy CLI, configuration, debugging | alloy.jmk, config.json, custom XML tags |
187
188
 
188
189
  Notes:
189
- - The first five (`ti-expert`, `purgetss`, `ti-ui`, `ti-game`, `ti-synthengine`) are specialist workflow skills reflecting TiTools' Titanium conventions and native-module contracts.
190
+ - The first six (`ti-reuse-first`, `ti-expert`, `purgetss`, `ti-ui`, `ti-game`, `ti-synthengine`) are specialist workflow skills reflecting TiTools' Titanium conventions and native-module contracts.
191
+ - `ti-reuse-first` runs before the others. It asks whether a thing needs to exist and what already does it — a search order that stops at the first rung that holds — and sweeps an existing `app/` for near-identical controllers, single-listener events, wrappers around one call, and layout recomputed in JavaScript. It is deliberately two-sided: it says when adding a module or a Widget is the right answer, so the bias does not simply invert.
190
192
  - `ti-game` documents the `ti.game` native module (2D sprite engine on OpenGL ES 2.0, Android and iOS). Its API reference was verified against the module source and tracks upstream `main` (the module ships features ahead of its manifest version); it applies to Alloy and Classic projects alike and does not depend on PurgeTSS.
191
193
  - `ti-synthengine` translates aesthetic sound requests into strict, production-ready JavaScript for the cross-platform `ti.synthengine` 1.0.0 module. Seven references preserve the official documentation and an eighth adds curated retro, xylophone, memory-pad, polyphony and timer-ownership recipes; the API contract was cross-checked against both native implementations.
192
194
  - The latter five are documentation-mirror skills sourced from the official Titanium SDK docs (`tidev/titanium-docs`) and audited against them via the internal `titools-skill-auditor`.
@@ -249,7 +251,7 @@ TiTools provides slash commands for common tasks. Both channels ship them: the p
249
251
  These commands automatically invoke the relevant skills:
250
252
  - `/ti-check` reads `tiapp.xml`, validates declared native modules, and reports project health
251
253
  - `/ti-new-screen` uses `ti-expert` + `purgetss` to create properly structured files
252
- - `/ti-audit` routes all 10 skills: core Titanium checks always, Alloy/PurgeTSS checks when detected, integration how-tos when used, and `ti-game` / `ti-synthengine` checks when those modules are declared
254
+ - `/ti-audit` routes all 11 skills: the reuse sweep first, core Titanium checks always, Alloy/PurgeTSS checks when detected, integration how-tos when used, and `ti-game` / `ti-synthengine` checks when those modules are declared
253
255
 
254
256
  ---
255
257
 
@@ -289,6 +291,7 @@ All skills include automatic project detection to ensure compatibility:
289
291
  | Skill | What It Detects | How It Works |
290
292
  | --------- | --------------------- | ------------------------------------------------------------- |
291
293
  | purgetss | PurgeTSS mode and project type | Checks PurgeTSS markers, then separates Alloy utilities from Classic standalone commands |
294
+ | ti-reuse-first | Existing app structure | Searches `app/lib/`, `app/controllers/`, `app/widgets/`, `purgetss/config.cjs` before proposing anything new |
292
295
  | ti-expert | Alloy vs Classic | Checks for `app/` (Alloy) vs `Resources/` (Classic) structure |
293
296
  | ti-ui | Titanium projects | Checks for `tiapp.xml` (both Alloy and Classic) |
294
297
  | ti-game | ti.game module | Checks for `<module>ti.game</module>` in `tiapp.xml` or `require('ti.game')` |
@@ -301,16 +304,17 @@ Why this matters:
301
304
 
302
305
  ### Skill hierarchy
303
306
 
304
- `ti-expert` is the architectural starting point and coordinates with the rest of the bundled catalog:
307
+ `ti-reuse-first` runs first — it decides whether there is anything to build — and `ti-expert` is the architectural starting point for what survives that question. Together they coordinate the rest of the bundled catalog:
305
308
 
306
309
  | Scope | Skills |
307
310
  | --- | --- |
311
+ | Whether to build it at all | `ti-reuse-first`, first |
308
312
  | Architecture and UI | `ti-expert`, `ti-ui`, `purgetss` when configured |
309
313
  | Native modules | `ti-game`, `ti-synthengine` when declared |
310
314
  | Exact SDK/API guidance | `ti-api`, `ti-guides`, `ti-howtos` |
311
315
  | Alloy MVC and tooling | `alloy-guides`, `alloy-howtos` for Alloy projects |
312
316
 
313
- All ten ship with TiTools; no second skill package is required.
317
+ All eleven ship with TiTools; no second skill package is required.
314
318
 
315
319
  ---
316
320
 
@@ -804,6 +808,7 @@ This pattern is documented across four bundled TiTools skills: `ti-ui`, `ti-expe
804
808
 
805
809
  | Skill | SKILL.md | References |
806
810
  | --------- | ----------------------------- | ----------------------------------------------------- |
811
+ | ti-reuse-first | Reuse Before You Write | 5 files (the ladder, Alloy reuse, indirection, PurgeTSS reuse, diagnostics) |
807
812
  | ti-expert | Architecture + Implementation | 26 files (patterns, feedback surfaces, file type association, sharing, testing, security, etc.) |
808
813
  | purgetss | Setup + Critical Rules | 34 files (Classic compatibility, grid, animations, icons, class-index, SVG pipeline, etc.) |
809
814
  | ti-ui | UI Rules + Platform Diffs | 14 files (layouts, lists, gestures, etc.) |
package/agents/ti-pro.md CHANGED
@@ -2,6 +2,7 @@
2
2
  name: ti-pro
3
3
  description: Titanium SDK and Alloy research specialist. Loads the complete TiTools skill catalog and uses the relevant scopes together for research-style questions. Use for codebase analysis, architecture reviews, Titanium SDK implementation research, cross-feature questions, native game or sound modules, and platform-specific differences. Returns concrete findings with file and line references.
4
4
  skills:
5
+ - ti-reuse-first
5
6
  - ti-expert
6
7
  - purgetss
7
8
  - ti-ui
@@ -24,6 +25,7 @@ You are a research specialist for Titanium SDK and Alloy mobile development. Wit
24
25
 
25
26
  When invoked, research complex topics by consulting the applicable skills from the complete TiTools catalog:
26
27
 
28
+ - `ti-reuse-first` - Whether the thing should exist at all, and what already does it; duplication and dead-structure sweeps
27
29
  - `ti-expert` - Architecture, patterns, conventions
28
30
  - `purgetss` - Utility-first styling classes (optional add-on; use when the project already has it or the user asks about it)
29
31
  - `ti-ui` - UI/UX patterns, layouts, gestures
@@ -56,6 +58,7 @@ Do not force conditional skills onto an unrelated project. State which scopes we
56
58
 
57
59
  | Use Instead | Reason |
58
60
  | -------------------------------------------- | -------------------------------------- |
61
+ | `/ti-reuse-first` before writing new code | Inline reuse check during development |
59
62
  | `/ti-expert` for architecture guidance | Inline consultation during development |
60
63
  | `/purgetss` to verify a class | Quick inline reference |
61
64
  | `/ti-ui` for UI/UX patterns and layouts | Task-oriented guidance |
@@ -7,22 +7,23 @@ Audit the current Titanium project by invoking every applicable TiTools skill. F
7
7
 
8
8
  Core Titanium audit:
9
9
 
10
- 1. Invoke `ti-expert` — check architecture, navigation ownership, event/timer cleanup, memory leaks, bridge pressure, testing boundaries, adaptive layouts and code conventions.
11
- 2. Invoke `ti-guides` — check `tiapp.xml`, CommonJS/Hyperloop usage, Titanium CLI/build assumptions, memory and bridge guidance, distribution settings, and toolchain compatibility.
12
- 3. Invoke `ti-api` — verify every audited `Ti.*` API, event, property, constant and third-party module call against its exact signature. Do not report a suspected API defect from memory.
13
- 4. Invoke `ti-ui` when the project creates Titanium UI — check layouts, list performance, event bubbling, gestures, animations, accessibility, orientation and platform-specific UI.
14
- 5. Invoke `ti-howtos` when the project implements a native integration such as location, maps, push, camera, media, SQLite, HTTPClient, WebView, intents, Keychain/iCloud, Hyperloop or CI/CD — audit the complete integration and platform setup, not only the JavaScript call site.
10
+ 1. Invoke `ti-reuse-first` — before judging how anything is built, establish whether it should exist and what already does it: near-identical controllers, panels copied instead of parameterized, events with a single listener, wrappers around one call, repeated class strings, and layout recomputed in JavaScript that TSS resolves declaratively. Report each finding with the command that measured it, and with what consolidating it would cost as well as save — duplication kept on purpose is a valid answer.
11
+ 2. Invoke `ti-expert` — check architecture, navigation ownership, event/timer cleanup, memory leaks, bridge pressure, testing boundaries, adaptive layouts and code conventions.
12
+ 3. Invoke `ti-guides` — check `tiapp.xml`, CommonJS/Hyperloop usage, Titanium CLI/build assumptions, memory and bridge guidance, distribution settings, and toolchain compatibility.
13
+ 4. Invoke `ti-api` — verify every audited `Ti.*` API, event, property, constant and third-party module call against its exact signature. Do not report a suspected API defect from memory.
14
+ 5. Invoke `ti-ui` when the project creates Titanium UI — check layouts, list performance, event bubbling, gestures, animations, accessibility, orientation and platform-specific UI.
15
+ 6. Invoke `ti-howtos` when the project implements a native integration such as location, maps, push, camera, media, SQLite, HTTPClient, WebView, intents, Keychain/iCloud, Hyperloop or CI/CD — audit the complete integration and platform setup, not only the JavaScript call site.
15
16
 
16
17
  Project-type audit:
17
18
 
18
- 6. If the project is Alloy (`app/views/`, `app/controllers/`, `app/styles/`), invoke `alloy-guides` — check controllers, XML/TSS, models, collections, widgets, sync adapters and cleanup patterns.
19
- 7. If the Alloy project uses `alloy.jmk`, `config.json`, `widget.json`, conditional views, custom XML tags or Alloy CLI workflows, invoke `alloy-howtos` — check configuration precedence, build hooks, generated structures and compilation/debugging conventions.
20
- 8. If the project uses PurgeTSS (`purgetss/`, `purgetss/config.cjs`, a PurgeTSS-generated `app.tss`, or `purgetss.{ui,colors,fonts}.js`), invoke `purgetss` and detect Alloy vs. Classic before applying findings. In Alloy, audit unsupported utility classes, padding on Views, unnecessary manual TSS, dynamic components, grids, icons and animations. In Classic, audit only the supported standalone asset/CommonJS commands and their `Resources/` outputs; do not require or recommend `alloy.jmk`, generated TSS, utility classes, or `$.UI.create()`.
19
+ 7. If the project is Alloy (`app/views/`, `app/controllers/`, `app/styles/`), invoke `alloy-guides` — check controllers, XML/TSS, models, collections, widgets, sync adapters and cleanup patterns.
20
+ 8. If the Alloy project uses `alloy.jmk`, `config.json`, `widget.json`, conditional views, custom XML tags or Alloy CLI workflows, invoke `alloy-howtos` — check configuration precedence, build hooks, generated structures and compilation/debugging conventions.
21
+ 9. If the project uses PurgeTSS (`purgetss/`, `purgetss/config.cjs`, a PurgeTSS-generated `app.tss`, or `purgetss.{ui,colors,fonts}.js`), invoke `purgetss` and detect Alloy vs. Classic before applying findings. In Alloy, audit unsupported utility classes, padding on Views, unnecessary manual TSS, dynamic components, grids, icons and animations. In Classic, audit only the supported standalone asset/CommonJS commands and their `Resources/` outputs; do not require or recommend `alloy.jmk`, generated TSS, utility classes, or `$.UI.create()`.
21
22
 
22
23
  Native-module audit:
23
24
 
24
- 9. If the project declares `ti.game`, invoke `ti-game` — check for sprites moved from timers instead of native properties, levels sized from `Ti.Platform.displayCaps` instead of the `resize` event, sprites added one by one instead of in one array, and timers or sounds left running when the window closes.
25
- 10. If the project declares `ti.synthengine`, invoke `ti-synthengine` — check strict option names and types, mutually exclusive pitch fields, valid envelope intersections, checked Boolean results, one clear lifecycle owner, delayed startup after layout, app-owned timer cancellation, and `shutdown()` cleanup.
25
+ 10. If the project declares `ti.game`, invoke `ti-game` — check for sprites moved from timers instead of native properties, levels sized from `Ti.Platform.displayCaps` instead of the `resize` event, sprites added one by one instead of in one array, and timers or sounds left running when the window closes.
26
+ 11. If the project declares `ti.synthengine`, invoke `ti-synthengine` — check strict option names and types, mutually exclusive pitch fields, valid envelope intersections, checked Boolean results, one clear lifecycle owner, delayed startup after layout, app-owned timer cancellation, and `shutdown()` cleanup.
26
27
 
27
28
  If a conditional skill does not apply, record it as `not applicable` with the detected reason instead of inventing findings from it.
28
29
 
@@ -6,6 +6,7 @@ argument-hint: "<screenName>"
6
6
 
7
7
  Create a new Alloy screen with the given name. Before creating files:
8
8
 
9
+ 0. Invoke the `ti-reuse-first` skill and confirm the screen has to be new. Run rung 1 against this project: list `app/controllers/` and `app/widgets/` and check whether an existing screen covers the case with a parameter, or whether a `<Require>` into an existing window is enough. If the answer is that an existing screen should be parameterized instead, say so, name it, and **stop** — do not generate the trio. Only continue when a new screen is genuinely the answer.
9
10
  1. Invoke the `alloy-guides` skill to follow Alloy MVC patterns
10
11
  2. If PurgeTSS is detected (purgetss/ folder exists), invoke the `purgetss` skill — all styling MUST use utility classes in the XML, NOT custom TSS
11
12
  3. If PurgeTSS is detected, invoke the `ti-ui` skill for layout patterns
package/lib/config.js CHANGED
@@ -30,6 +30,7 @@ export const REPO_API_URL = 'https://api.github.com/repos/macCesar/titools';
30
30
 
31
31
  // Skills to install
32
32
  export const SKILLS = [
33
+ 'ti-reuse-first',
33
34
  'ti-expert',
34
35
  'purgetss',
35
36
  'ti-ui',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maccesar/titools",
3
- "version": "4.20.0",
3
+ "version": "4.22.0",
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",
@@ -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
  ---
@@ -9,12 +9,15 @@ allowed-tools: Read, Grep, Glob, Edit, Write, Bash(git *), Bash(node *)
9
9
 
10
10
  Practical architecture and implementation guidance for Titanium SDK apps (Alloy and Classic). Focus on maintainability, clear boundaries, and low-friction testing.
11
11
 
12
+ > **Before anything new exists, run the `ti-reuse-first` skill.** It answers *whether* a controller, view, Widget, `app/lib/` module, event or wrapper should exist at all and what already does it; this guide answers *how* to build whatever survives that question. Copying an existing controller as a template counts as creating something new.
13
+
12
14
  ## Required workflow
13
15
 
14
16
  This file is an index. Read the references that govern the task before making a recommendation or changing code.
15
17
 
16
18
  | Task | Required reading |
17
19
  | --- | --- |
20
+ | Anything new — a controller/view/style trio, Widget, `app/lib/` module, event, wrapper, or a file copied as a template | Run the `ti-reuse-first` skill first, then the row below that matches what survives |
18
21
  | Architecture tier, folders, or boundaries | [Architecture tiers](references/architecture-tiers.md), [Alloy structure](references/alloy-structure.md) |
19
22
  | Feedback, alerts, choices, or app-owned versus system UI | [Feedback surfaces](references/feedback-surfaces.md) |
20
23
  | Reusable Snackbar, Dialog, Bottom Sheet, Widget, or `<Require>` | [Feedback Widget contracts](references/feedback-widget-contracts.md), [Alloy structure](references/alloy-structure.md) |
@@ -23,6 +26,7 @@ This file is an index. Read the references that govern the task before making a
23
26
  | Tablet, foldable, large screen, or responsive behavior | [Adaptive layouts](references/adaptive-layouts.md) |
24
27
  | Opening or receiving files: custom extensions, UTIs, intent filters, AirDrop, Quick Share, document icons | [File type association](references/file-type-association.md) |
25
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) |
26
30
  | Security architecture | [Security fundamentals](references/security-fundamentals.md), [Device security](references/security-device.md) |
27
31
  | Testing or performance | Load the matching guide under Quality & Performance below |
28
32
 
@@ -151,14 +155,14 @@ For the complete reference with examples, see [Alloy builtins and globals](refer
151
155
  | ---------------------------------- | -------------------------------------------------------------- |
152
156
  | How to create a new Alloy project? | `ti create -t app --alloy` (not `--classic` + `alloy new`) |
153
157
  | Fastest way to build? | `tn <recipe>` (using TiNy CLI wrapper) |
154
- | Controller > 100 lines? | Extract to Tier 2 (Services) |
158
+ | Controller > 100 lines? | A smell, not a threshold — see `ti-reuse-first` |
155
159
  | More than 50 IDs in XML? | Use Tier 3 (ID Scoping) |
156
160
  | Where does API call go? | `lib/api/` |
157
161
  | Where does business logic go? | `lib/services/` |
158
162
  | How deep should `lib` folders be? | One level: `lib/<type>/<file>.js` |
159
163
  | Where do I store auth tokens? | Keychain (iOS) / KeyStore (Android) via service |
160
164
  | Models or Collections? | Collections for API data, Models for SQLite persistence |
161
- | Ti.App.fireEvent or EventBus? | Always EventBus (Backbone.Events) |
165
+ | Ti.App.fireEvent or EventBus? | EventBus — once `ti-reuse-first` says it needs an event |
162
166
  | Direct navigation or service? | Always Navigation service (auto cleanup) |
163
167
  | Snackbar, Dialog, or Bottom Sheet? | Classify urgency, persistence, reversibility, and choice first |
164
168
  | Widget or `<Require>`? | Widget for a self-contained API/lifecycle; `<Require>` for app composition |
@@ -182,6 +186,7 @@ For the complete reference with examples, see [Alloy builtins and globals](refer
182
186
  - [Controller Patterns](references/controller-patterns.md)
183
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)
184
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)
185
190
  - [Feedback Surfaces & App-Owned UI](references/feedback-surfaces.md)
186
191
  - [Feedback Widget Contracts](references/feedback-widget-contracts.md)
187
192
  - [Feedback Migration](references/feedback-migration.md)
@@ -15,6 +15,8 @@
15
15
 
16
16
  <!-- TOC-END -->
17
17
 
18
+ > **Before introducing any of these:** each pattern below adds a layer, and a layer only pays for itself where there is a boundary — a place the implementation could plausibly change, or where the two sides genuinely should not know each other. Around a single call site with one caller, the same pattern is a detour that costs on every read. The `ti-reuse-first` skill runs that check; use it first, and treat the patterns here as the answer to *how* once it has established *whether*.
19
+
18
20
  ## 1. Native module abstraction (Wrapper)
19
21
 
20
22
  **Use when:** Using native modules like Maps, Biometrics, or specialized Media players.
@@ -0,0 +1,308 @@
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
+ - [Contents](#contents)
15
+ - [1. The four doors](#1-the-four-doors)
16
+ - [2. The payload decides how much of the module runs](#2-the-payload-decides-how-much-of-the-module-runs)
17
+ - [3. A tap does not fire `didReceiveMessage`](#3-a-tap-does-not-fire-didreceivemessage)
18
+ - [4. The case nobody documents: tapping with the app in front](#4-the-case-nobody-documents-tapping-with-the-app-in-front)
19
+ - [5. Why two splash screens flash by](#5-why-two-splash-screens-flash-by)
20
+ - [6. Sending the message](#6-sending-the-message)
21
+ - [7. Every value inside `data` is a string](#7-every-value-inside-data-is-a-string)
22
+ - [8. iOS](#8-ios)
23
+ - [9. The device may sit on your push for twenty minutes](#9-the-device-may-sit-on-your-push-for-twenty-minutes)
24
+ - [10. Symptom to cause](#10-symptom-to-cause)
25
+
26
+ <!-- TOC-END -->
27
+
28
+ ## 1. The four doors
29
+
30
+ | What the app was doing | Where the payload arrives | Requires |
31
+ | --- | --- | --- |
32
+ | Open, in front, message arrives | `didReceiveMessage` on the module | nothing |
33
+ | Closed, person taps the notification | `fcm_data` extra on the launch Intent | reading it at startup |
34
+ | Backgrounded, person taps the notification | `Ti.App` `resumed`, then read the Intent | nothing |
35
+ | **Already in front**, person taps a notification sitting in the tray | `newintent` on the root activity | `launchMode="singleTop"` |
36
+
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).
38
+
39
+ 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
+
41
+ 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.
42
+
43
+ A single handler serves all four:
44
+
45
+ ```javascript
46
+ if (OS_ANDROID) {
47
+ const mostrarAviso = (intentNuevo, origen) => {
48
+ const intent = intentNuevo || Ti.Android.rootActivity.intent
49
+ const contenido = intent.getStringExtra('fcm_data')
50
+
51
+ if (!contenido) {
52
+ log.debug(`Sin aviso en el Intent (${origen})`)
53
+ return
54
+ }
55
+
56
+ let datos
57
+ try {
58
+ datos = JSON.parse(contenido)
59
+ } catch (error) {
60
+ modulo.clearLastData()
61
+ return
62
+ }
63
+
64
+ // Consume it, or the same payload fires again on the next resume.
65
+ modulo.clearLastData()
66
+ log.info(`Aviso abierto (${origen})`)
67
+ abrirPanel(datos)
68
+ }
69
+
70
+ // Both events pass an argument of their own, so they are wrapped: only
71
+ // 'newintent' carries an Intent and only it should be forwarded.
72
+ Ti.App.addEventListener('resumed', () => mostrarAviso(null, 'resumed'))
73
+ Ti.Android.rootActivity.addEventListener('newintent', (e) => mostrarAviso(e.intent, 'newintent'))
74
+
75
+ mostrarAviso(null, 'arranque')
76
+ }
77
+ ```
78
+
79
+ **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.
80
+
81
+ **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
+
83
+ ## 2. The payload decides how much of the module runs
84
+
85
+ Android distinguishes a *notification message* from a *data message*, and the difference is not cosmetic.
86
+
87
+ 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:
88
+
89
+ - `image`, `big_text`, `big_text_summary`, `icon`, `rounded_large_icon`, `force_show_in_foreground`, `id`, `color`, `vibrate`, `sound` and `badge` are all ignored.
90
+ - 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.
91
+
92
+ 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`.
93
+
94
+ Firebase's own server documentation teaches the `notification` block, which is why this is such a common shape to arrive at.
95
+
96
+ ## 3. A tap does not fire `didReceiveMessage`
97
+
98
+ 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`.
99
+
100
+ 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:
101
+
102
+ - **App closed.** It starts, calls register, `parseBootIntent()` runs, `didReceiveMessage` fires.
103
+ - **App backgrounded.** It resumes, register is not called again, nothing fires. The payload sits on the Intent and nobody reads it.
104
+
105
+ 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.
106
+
107
+ 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.
108
+
109
+ ## 4. The case nobody documents: tapping with the app in front
110
+
111
+ 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.
112
+
113
+ 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.
114
+
115
+ This is not an edge case. It is the most ordinary way a stored notification gets opened.
116
+
117
+ It needs two things, and neither works without the other:
118
+
119
+ ```xml
120
+ <!-- tiapp.xml, inside <application> -->
121
+ <activity android:name="com.yourdomain.yourapp.YourAppActivity"
122
+ android:launchMode="singleTop" tools:node="merge" />
123
+ ```
124
+
125
+ ```javascript
126
+ Ti.Android.rootActivity.addEventListener('newintent', (e) => mostrarAviso(e.intent, 'newintent'))
127
+ ```
128
+
129
+ 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.
130
+
131
+ `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.
132
+
133
+ Take the activity name from the built manifest rather than guessing it: `build/android/app/build/intermediates/merged_manifest/debug/processDebugMainManifest/AndroidManifest.xml`.
134
+
135
+ ### If your module fires `didOpenNotification`
136
+
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.
138
+
139
+ ```javascript
140
+ modulo.addEventListener('didOpenNotification', (e) => {
141
+ abrirPanel((e.message && e.message.data) || {})
142
+ })
143
+ ```
144
+
145
+ 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
+
147
+ 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
+
149
+ 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
+
151
+ ## 5. Why two splash screens flash by
152
+
153
+ 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.
154
+
155
+ Two separate causes, and it is worth knowing which is which before trying to fix either.
156
+
157
+ **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.
158
+
159
+ **Titanium's resume mechanism.** `TiRootActivity.onCreate()` builds the only intent it accepts:
160
+
161
+ ```java
162
+ // This is the only intent Titanium supports in order to simulate "singleTask" like resume behavior.
163
+ Intent mainIntent = Intent.makeMainActivity(getComponentName());
164
+ mainIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
165
+ mainIntent.addFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
166
+ mainIntent.setPackage(getPackageName());
167
+ ```
168
+
169
+ 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.
170
+
171
+ 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.
172
+
173
+ 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.
174
+
175
+ ## 6. Sending the message
176
+
177
+ 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.
178
+
179
+ 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.
180
+
181
+ ```php
182
+ <?php
183
+
184
+ require __DIR__ . '/vendor/autoload.php';
185
+
186
+ use Kreait\Firebase\Factory;
187
+ use Kreait\Firebase\Messaging\CloudMessage;
188
+
189
+ $factory = (new Factory)->withServiceAccount('/path/to/service-account.json');
190
+ $messaging = $factory->createMessaging();
191
+
192
+ $message = CloudMessage::new()
193
+ ->withToken($token)
194
+ ->withData([
195
+ 'title' => 'Title',
196
+ 'message' => 'Body',
197
+ 'channelId' => 'default_channel',
198
+ ]);
199
+
200
+ $messaging->send($message);
201
+ ```
202
+
203
+ `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.
204
+
205
+ `channelId` must match a channel your app created, or Android 8 and later drop the notification with nothing in the log to say so.
206
+
207
+ **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.
208
+
209
+ 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.
210
+
211
+ ## 7. Every value inside `data` is a string
212
+
213
+ 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.
214
+
215
+ 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.
216
+
217
+ An `array_map('strval', ...)` over the whole payload before sending saves the round trip.
218
+
219
+ ## 8. iOS
220
+
221
+ iOS has one door, and it works. Everything in section 1 is an Android problem.
222
+
223
+ `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.
224
+
225
+ | What the app was doing | Panel shown |
226
+ | --- | --- |
227
+ | Open, in front, message arrives | yes |
228
+ | Backgrounded, person taps | yes |
229
+ | Already in front, person taps a stored notification | yes |
230
+ | Closed, person taps | yes |
231
+
232
+ 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".
233
+
234
+ ```javascript
235
+ Ti.Network.registerForPushNotifications({
236
+ success: (e) => { enviarTokenAlServidor(e.deviceToken) },
237
+ error: (e) => { log.error(e.error) },
238
+ callback: (e) => {
239
+ // Foreground, background and closed all arrive here.
240
+ const datos = (e && e.data) || {}
241
+ abrirPanel(datos)
242
+ }
243
+ })
244
+ ```
245
+
246
+ 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.
247
+
248
+ **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.
249
+
250
+ 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.
251
+
252
+ ```javascript
253
+ // alloy.js
254
+ if (OS_IOS) {
255
+ const FirebaseCore = require('firebase.core')
256
+ FirebaseCore.configure()
257
+ require('firebase.cloudmessaging')
258
+ }
259
+ ```
260
+
261
+ **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
+
263
+ ```php
264
+ 'data' => ['title' => $titulo, 'message' => $cuerpo, 'channelId' => 'default_channel'],
265
+ 'apns' => [
266
+ 'headers' => ['apns-priority' => '10'],
267
+ 'payload' => ['aps' => ['alert' => ['title' => $titulo, 'body' => $cuerpo], 'sound' => 'default']],
268
+ ],
269
+ ```
270
+
271
+ Forgetting the `apns` block is a quiet failure: Android works, iOS receives the message and displays nothing, and the payload is identical in both.
272
+
273
+ **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.
274
+
275
+ 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`.
276
+
277
+ **`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:
278
+
279
+ ```javascript
280
+ // Not: if (args.imagen) { $.preview.applyProperties({ image: args.imagen }) }
281
+ $.preview.applyProperties({ image: args.imagen || LOGO })
282
+ ```
283
+
284
+ 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.
285
+
286
+ ## 9. The device may sit on your push for twenty minutes
287
+
288
+ 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.
289
+
290
+ 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.
291
+
292
+ Do not test with the app added to the battery whitelist. That measures a phone your users do not have.
293
+
294
+ ## 10. Symptom to cause
295
+
296
+ | Symptom | Cause | Where to look |
297
+ | --- | --- | --- |
298
+ | Tapping restarts the app from zero and loses state | Top-level `notification` block; the system owns the intent | Section 2 |
299
+ | `image`, `big_text`, `channelId`, `sound`, `vibrate` ignored | Same | Section 2 |
300
+ | Foreground and cold start work, backgrounded tap does nothing | Nobody reads the Intent on resume | Section 3 |
301
+ | Nothing at all happens when tapping a stored notification while using the app | No `resumed`, no `newintent` listener, or no `singleTop` | Section 4 |
302
+ | Blank pages slide past before the app appears | `PushHandlerActivity` has no theme; Titanium spawns a duplicate root activity | Section 5 |
303
+ | Nothing is delivered and no error comes back | Legacy FCM API, shut down in 2024 | Section 6 |
304
+ | `INVALID_ARGUMENT` naming a `data` field | A non-string value | Section 7 |
305
+ | Android shows the notification, iOS shows nothing | No `apns.payload.aps.alert` in the message | Section 8 |
306
+ | iOS registers but never gets a token | Firebase loaded after APNs answered | Section 8 |
307
+ | iOS receives the push and does nothing when tapped | Empty `callback` | Section 8 |
308
+ | Nothing arrives for twenty minutes, then five at once | OEM battery management | Section 9 |