@maccesar/titools 4.4.0 → 4.4.2
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 +9 -12
- package/package.json +1 -1
- package/skills/purgetss/SKILL.md +4 -9
- package/skills/ti-api/references/api-core.md +24 -69
- package/skills/ti-api/references/api-data-network.md +18 -63
- package/skills/ti-api/references/api-media.md +15 -49
- package/skills/ti-api/references/api-modules-coremotion-urlsession.md +6 -20
- package/skills/ti-api/references/api-modules-map.md +17 -40
- package/skills/ti-api/references/api-modules-nfc.md +23 -56
- package/skills/ti-api/references/api-ui-android.md +16 -37
- package/skills/ti-api/references/api-ui-extras.md +28 -72
- package/skills/ti-api/references/api-ui-lists.md +14 -35
- package/skills/ti-api/references/api-ui-text-input.md +13 -39
- package/skills/ti-api/references/api-ui-windows-navigation.md +17 -43
- package/skills/ti-api/references/api-xml-global.md +25 -60
- package/skills/ti-expert/SKILL.md +8 -11
- package/skills/ti-expert/references/adaptive-layouts.md +15 -43
- package/skills/ti-expert/references/alloy-builtins.md +1 -4
- package/skills/ti-expert/references/alloy-structure.md +4 -12
- package/skills/ti-expert/references/anti-patterns.md +16 -45
- package/skills/ti-expert/references/code-conventions.md +2 -6
- package/skills/ti-expert/references/controller-patterns.md +1 -3
- package/skills/ti-expert/references/examples.md +3 -10
- package/skills/ti-expert/references/feedback-migration.md +17 -39
- package/skills/ti-expert/references/feedback-surfaces.md +19 -48
- package/skills/ti-expert/references/feedback-widget-contracts.md +31 -69
- package/skills/ti-expert/references/file-type-association.md +265 -0
- package/skills/ti-expert/references/migration-patterns.md +8 -18
- package/skills/ti-expert/references/sharing.md +227 -0
- package/skills/ti-expert/references/theming.md +20 -45
- package/skills/ti-guides/references/coding-best-practices.md +1 -2
- package/skills/ti-guides/references/style-and-conventions.md +1 -2
- package/skills/ti-howtos/references/debugging-profiling.md +1 -2
- package/skills/ti-howtos/references/location-and-maps.md +1 -2
- package/skills/ti-howtos/references/using-modules.md +1 -2
- package/skills/ti-howtos/references/web-content-integration.md +1 -2
- package/skills/ti-howtos/references/webpack-build-pipeline.md +1 -2
- package/skills/ti-ui/references/listviews-and-performance.md +5 -10
- package/skills/ti-ui/references/orientation.md +1 -2
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# File type association
|
|
2
|
+
|
|
3
|
+
How to make the OS hand your app a file — so a document with your extension opens your app when the user taps it, arrives over AirDrop or Quick Share, or is picked from another app's share sheet.
|
|
4
|
+
|
|
5
|
+
This is a configuration problem more than a coding one, and it fails in a specific way that wastes days: the system gives no error, no warning, and no log line when a key is in the wrong place. The app simply never gets the file, and every symptom looks like "iOS/Android doesn't allow this", which is the wrong conclusion. Most of this file is about telling those failures apart.
|
|
6
|
+
|
|
7
|
+
## Contents
|
|
8
|
+
|
|
9
|
+
1. [Decide what you are declaring](#1-decide-what-you-are-declaring)
|
|
10
|
+
2. [iOS: declaring the type](#2-ios-declaring-the-type)
|
|
11
|
+
3. [iOS: claiming the documents](#3-ios-claiming-the-documents)
|
|
12
|
+
4. [iOS: the root-level keys](#4-ios-the-root-level-keys)
|
|
13
|
+
5. [iOS: receiving the file](#5-ios-receiving-the-file)
|
|
14
|
+
6. [Android: the intent filter](#6-android-the-intent-filter)
|
|
15
|
+
7. [Android: receiving the file](#7-android-receiving-the-file)
|
|
16
|
+
8. [Document icons](#8-document-icons)
|
|
17
|
+
9. [Verify instead of assuming](#9-verify-instead-of-assuming)
|
|
18
|
+
10. [Symptom to cause](#10-symptom-to-cause)
|
|
19
|
+
|
|
20
|
+
## 1. Decide what you are declaring
|
|
21
|
+
|
|
22
|
+
Two questions decide everything downstream.
|
|
23
|
+
|
|
24
|
+
**Is the type yours or someone else's?** A format your app invented is *exported* — you own the identifier and define what it means. A format that already exists (PDF, CSV, a competitor's format) is *imported* — you are declaring that you can handle a type someone else defined. On iOS these are different keys (`UTExportedTypeDeclarations` vs `UTImportedTypeDeclarations`); declaring someone else's type as exported is how two apps end up fighting over an identifier.
|
|
25
|
+
|
|
26
|
+
**What is the file, as far as the system is concerned?** This matters more than it looks, and it is the first place people go wrong. A backup archive that happens to be a ZIP internally is *not* a ZIP as far as the user is concerned — it is your document. If you declare it as conforming to `public.zip-archive`, iOS Files treats it as an archive: tapping it lists the contents instead of handing it to your app, because that is what the system does with archives, and it is doing exactly what you told it.
|
|
27
|
+
|
|
28
|
+
Conform to `public.data` for an opaque document whose internals are your business. The internal format is an implementation detail; the declared type is a user-facing contract.
|
|
29
|
+
|
|
30
|
+
The same reasoning applies to the extension. `.zip` makes your app volunteer for every ZIP on the device; a distinct extension keeps the association narrow.
|
|
31
|
+
|
|
32
|
+
## 2. iOS: declaring the type
|
|
33
|
+
|
|
34
|
+
Goes inside `<ios><plist><dict>` in `tiapp.xml`:
|
|
35
|
+
|
|
36
|
+
```xml
|
|
37
|
+
<key>UTExportedTypeDeclarations</key>
|
|
38
|
+
<array>
|
|
39
|
+
<dict>
|
|
40
|
+
<key>UTTypeIdentifier</key>
|
|
41
|
+
<string>com.example.myapp.mytype</string>
|
|
42
|
+
<key>UTTypeDescription</key>
|
|
43
|
+
<string>My App Document</string>
|
|
44
|
+
<key>UTTypeConformsTo</key>
|
|
45
|
+
<array>
|
|
46
|
+
<string>public.data</string>
|
|
47
|
+
</array>
|
|
48
|
+
<key>UTTypeTagSpecification</key>
|
|
49
|
+
<dict>
|
|
50
|
+
<key>public.filename-extension</key>
|
|
51
|
+
<array>
|
|
52
|
+
<string>mytype</string>
|
|
53
|
+
</array>
|
|
54
|
+
</dict>
|
|
55
|
+
</dict>
|
|
56
|
+
</array>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Use reverse-DNS under your bundle id for the identifier. The extension in `UTTypeTagSpecification` carries no leading dot.
|
|
60
|
+
|
|
61
|
+
For a type you did not invent, use `UTImportedTypeDeclarations` with the same structure and the identifier its owner published.
|
|
62
|
+
|
|
63
|
+
## 3. iOS: claiming the documents
|
|
64
|
+
|
|
65
|
+
Declaring a type says the type exists. `CFBundleDocumentTypes` says your app handles it:
|
|
66
|
+
|
|
67
|
+
```xml
|
|
68
|
+
<key>CFBundleDocumentTypes</key>
|
|
69
|
+
<array>
|
|
70
|
+
<dict>
|
|
71
|
+
<key>CFBundleTypeName</key>
|
|
72
|
+
<string>My App Document</string>
|
|
73
|
+
<key>CFBundleTypeRole</key>
|
|
74
|
+
<string>Editor</string>
|
|
75
|
+
<key>LSHandlerRank</key>
|
|
76
|
+
<string>Owner</string>
|
|
77
|
+
<key>LSItemContentTypes</key>
|
|
78
|
+
<array>
|
|
79
|
+
<string>com.example.myapp.mytype</string>
|
|
80
|
+
</array>
|
|
81
|
+
<key>CFBundleTypeExtensions</key>
|
|
82
|
+
<array>
|
|
83
|
+
<string>mytype</string>
|
|
84
|
+
</array>
|
|
85
|
+
</dict>
|
|
86
|
+
</array>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`LSHandlerRank` decides who wins when several apps claim the same type: `Owner` for a format you defined, `Alternate` for one you merely support, `Default` when no other app claims it. `CFBundleTypeRole` is `Editor` if your app can modify the file, `Viewer` if it only reads it.
|
|
90
|
+
|
|
91
|
+
## 4. iOS: the root-level keys
|
|
92
|
+
|
|
93
|
+
This is where the day gets lost, so it is worth stating plainly: **`LSSupportsOpeningDocumentsInPlace` is a root-level `Info.plist` key.** It is a sibling of `CFBundleDocumentTypes`, not a key inside its dictionary.
|
|
94
|
+
|
|
95
|
+
Nesting it reads as natural — it describes how documents of that type are opened, so it looks like it belongs with the type. iOS does not read it there and says nothing about it. The resulting state is deceptive: the app *is* registered as owner of the type, so the system knows it exists, but nothing ever declared that the app can open it in place. Files falls back to previewing the document, and the behavior is indistinguishable from "iOS won't launch third-party apps from Files" — a conclusion people reach, write down, and design around.
|
|
96
|
+
|
|
97
|
+
```xml
|
|
98
|
+
<!-- correct: sibling of CFBundleDocumentTypes, at the root of the plist dict -->
|
|
99
|
+
<key>CFBundleDocumentTypes</key>
|
|
100
|
+
<array>...</array>
|
|
101
|
+
|
|
102
|
+
<key>LSSupportsOpeningDocumentsInPlace</key>
|
|
103
|
+
<true/>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Apple's own requirement makes this non-optional: when you declare `CFBundleDocumentTypes`, you must also set either `LSSupportsOpeningDocumentsInPlace` or `UISupportsDocumentBrowser` (the latter only if the app uses `UIDocumentBrowserViewController`). Omitting both also triggers the *Missing Document Configuration* warning from App Store Connect, which is often the first hint anyone gets that something is wrong.
|
|
107
|
+
|
|
108
|
+
There is a second reason to set it beyond launching. Without it, iOS copies the document into the app's Inbox before delivering it — a full second copy on disk. For a document that carries photos or video, that is a real cost paid every time the user opens one.
|
|
109
|
+
|
|
110
|
+
### The neighboring key that is not the same thing
|
|
111
|
+
|
|
112
|
+
`UIFileSharingEnabled` (also root-level) exposes the app's entire `Documents` directory inside the Files app. It is unrelated to opening a document your app was handed, and it is not required for this to work.
|
|
113
|
+
|
|
114
|
+
Enabling it makes every JSON file, cache and media asset the app keeps in `Documents` browsable by the user. That is a product decision — sometimes the right one, for an app whose documents *are* the user's files — but reaching for it while debugging a file association exposes the app's internals as a side effect of a fix that was never about that.
|
|
115
|
+
|
|
116
|
+
## 5. iOS: receiving the file
|
|
117
|
+
|
|
118
|
+
The app receives the document URL through the `handleurl` event on `Ti.App.iOS`:
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
Ti.App.iOS.addEventListener('handleurl', event => {
|
|
122
|
+
const url = event.launchOptions && event.launchOptions.url
|
|
123
|
+
// resolve, validate, then adopt
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Register the listener at a point that survives the app being launched *by* the file rather than merely resumed — a cold launch delivers the event early. Store whatever identifies the last handled document, because the same launch payload can be read more than once during a lifecycle and reprocessing it silently imports the same file twice.
|
|
128
|
+
|
|
129
|
+
**Validate before acting.** An intent filter or a UTI claim is a public invitation: any file with the right extension reaches your app, including one that is malformed or came from somewhere unexpected. Read the manifest, check the version, and fail with a message the user understands — never start writing over existing data on the strength of a filename.
|
|
130
|
+
|
|
131
|
+
### Saving a file back out
|
|
132
|
+
|
|
133
|
+
Two APIs are easy to confuse:
|
|
134
|
+
|
|
135
|
+
- `Ti.UI.iOS.DocumentViewer` is a **previewer**. If the file type is not previewable it does not present and does not report an error.
|
|
136
|
+
- `ti.documentpicker.export()` opens the system destination dialog — this is the one for "save a copy where the user chooses".
|
|
137
|
+
|
|
138
|
+
## 6. Android: the intent filter
|
|
139
|
+
|
|
140
|
+
Android associates by intent filter on the activity that should receive the file, declared inside `<android><manifest><application>` in `tiapp.xml`:
|
|
141
|
+
|
|
142
|
+
```xml
|
|
143
|
+
<activity android:name=".MyAppActivity">
|
|
144
|
+
<intent-filter>
|
|
145
|
+
<action android:name="android.intent.action.VIEW"/>
|
|
146
|
+
<category android:name="android.intent.category.DEFAULT"/>
|
|
147
|
+
<category android:name="android.intent.category.BROWSABLE"/>
|
|
148
|
+
<data android:scheme="content"/>
|
|
149
|
+
<data android:scheme="file"/>
|
|
150
|
+
<data android:mimeType="*/*"/>
|
|
151
|
+
<data android:host="*"/>
|
|
152
|
+
<data android:pathPattern=".*\\.mytype"/>
|
|
153
|
+
<data android:pathPattern=".*\\..*\\.mytype"/>
|
|
154
|
+
<data android:pathPattern=".*\\..*\\..*\\.mytype"/>
|
|
155
|
+
</intent-filter>
|
|
156
|
+
</activity>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Three things in that block are not obvious.
|
|
160
|
+
|
|
161
|
+
**`pathPattern` is not a regular expression.** It supports only `*` (zero or more of the preceding character) and `.` (any character), and the matching is greedy without backtracking. `.*\\.mytype` therefore fails on any filename containing an earlier dot — `my.backup.mytype` does not match — so the conventional fix is to repeat the pattern with one extra `.*\\.` per additional dot you want to tolerate. Three variants covers most real filenames. The backslash is doubled because the manifest is XML.
|
|
162
|
+
|
|
163
|
+
**A custom extension usually has no registered MIME type**, so the file arrives as `application/octet-stream` or with whatever type the sending app guessed. `android:mimeType="*/*"` is what makes the filter reachable at all; the `pathPattern` entries are what keep it from volunteering for every file on the device. Both halves are load-bearing — `*/*` alone makes your app an option for opening anything.
|
|
164
|
+
|
|
165
|
+
**Matching is by name, which has a consequence.** A `content://` URI whose path does not carry the filename will not match, no matter what its MIME type says. This is a real limit of the mechanism, not a misconfiguration to hunt down.
|
|
166
|
+
|
|
167
|
+
### Whether to also filter ACTION_SEND
|
|
168
|
+
|
|
169
|
+
`ACTION_VIEW` covers tapping the file — in a file manager, a messaging app, or a notification. `ACTION_SEND` covers another app sharing content *to* you, which is how Quick Share and most share buttons deliver.
|
|
170
|
+
|
|
171
|
+
Adding `ACTION_SEND` has a side effect worth predicting: your app then appears in its own share sheet, offering to receive the file it just produced. If the app's only outbound flow is exporting a document, that is confusing enough to be worth skipping — tapping the received file still works through `ACTION_VIEW`.
|
|
172
|
+
|
|
173
|
+
## 7. Android: receiving the file
|
|
174
|
+
|
|
175
|
+
Read the intent from the current activity, falling back to the launch intent:
|
|
176
|
+
|
|
177
|
+
```javascript
|
|
178
|
+
const activity = Ti.Android.currentActivity || Ti.Android.rootActivity
|
|
179
|
+
const intent = (activity && activity.intent) || Ti.App.Android.launchIntent
|
|
180
|
+
const action = intent.getAction()
|
|
181
|
+
|
|
182
|
+
// ACTION_VIEW → intent.getData()
|
|
183
|
+
// ACTION_SEND → intent.getBlobExtra(Ti.Android.EXTRA_STREAM)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**A `content://` URI is not a path.** It is a handle into another app's content provider, valid only for as long as the grant lasts. Read the blob and write it to a file you own under `applicationDataDirectory` before doing anything else; code that treats it as a filesystem path works on some devices and fails on others, which is the worst kind of bug to inherit.
|
|
187
|
+
|
|
188
|
+
Give that landing spot its own directory, separate from the working area your import routine clears at the start of a run — otherwise the incoming file deletes itself moments before it is read.
|
|
189
|
+
|
|
190
|
+
As on iOS, the same launch intent can be read more than once during a lifecycle. Remember the last one handled.
|
|
191
|
+
|
|
192
|
+
## 8. Document icons
|
|
193
|
+
|
|
194
|
+
A document icon is what lets someone find their file months later without reading filenames. It is also the part most likely to be quietly ignored by the system, so it is worth confirming before producing a full set of images.
|
|
195
|
+
|
|
196
|
+
**iOS derives one for free — check that before drawing anything.** With no icon keys declared at all, the Files app renders the document as a page with the app icon inside it (verified 2026-08-05 on a Titanium 13.4.0 app: no `UTTypeIconFiles`, no `CFBundleTypeIconFiles`, not a single document PNG in the project). For a type only your app produces, that is usually enough — it reads as "a document belonging to this app", which is the whole job, and it costs nothing to maintain.
|
|
197
|
+
|
|
198
|
+
Note the sequencing this implies: the derived icon is only meaningful once the app icon is final, since it is built from it. Producing a document icon set before app identity is settled means judging a fallback that is itself a placeholder.
|
|
199
|
+
|
|
200
|
+
If you do want a custom one, declare `UTTypeIconFiles` in the type declaration and `CFBundleTypeIconFiles` in the document type entry, both arrays of PNG filenames (with extension) placed in `app/assets/iphone/`. Confirm it is actually honored on a device before producing the full size set — there are long-standing developer reports of the system preferring its derived version anyway.
|
|
201
|
+
|
|
202
|
+
**Android.** The icon belongs on the `intent-filter`, not on the data element:
|
|
203
|
+
|
|
204
|
+
```xml
|
|
205
|
+
<intent-filter android:icon="@drawable/mytype">
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
with the drawable under `platform/android/res/drawable/`. Android does not oblige file managers to use it, and several show their own icon based on MIME type — with `application/octet-stream`, expect some to render it generic no matter what you ship.
|
|
209
|
+
|
|
210
|
+
Android has no derived-icon fallback, so the two platforms are not symmetrical: on iOS doing nothing produces something reasonable, while on Android doing nothing leaves the file generic. Decide them separately rather than as one task.
|
|
211
|
+
|
|
212
|
+
## 9. Verify instead of assuming
|
|
213
|
+
|
|
214
|
+
Every claim in this file is checkable in seconds, and the failure modes are silent enough that checking is the only way to know.
|
|
215
|
+
|
|
216
|
+
**Confirm the built binary, not `tiapp.xml`.** The build normalizes and merges plists, so what you wrote is not necessarily what shipped:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# what the installed app actually declares
|
|
220
|
+
plutil -extract LSSupportsOpeningDocumentsInPlace raw -o - "<App>.app/Info.plist"
|
|
221
|
+
plutil -extract CFBundleDocumentTypes xml1 -o - "<App>.app/Info.plist"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A key in the wrong place returns *"No value at that key path"* — the same result as not declaring it at all, which is precisely the point.
|
|
225
|
+
|
|
226
|
+
For a simulator build, locate the bundle with:
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
xcrun simctl get_app_container <device-udid> <bundle-id> app
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**On Android, ask the package manager who claims the type:**
|
|
233
|
+
|
|
234
|
+
```bash
|
|
235
|
+
adb shell cmd package query-activities -a android.intent.action.VIEW \
|
|
236
|
+
-d "file:///sdcard/Download/test.mytype"
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Run it twice — once with your extension and once with a neighbouring one (`.zip`, `.txt`) — to confirm the filter is both reachable *and* narrow. A filter that matches everything looks identical to a correct one until you test the negative case.
|
|
240
|
+
|
|
241
|
+
**The final check is a person tapping the file.** Nothing above proves the OS routes it; it only proves the declaration is well formed. Test a cold launch (app not running) separately from a warm one — they take different paths through the launch handling and only one of them may be wired up.
|
|
242
|
+
|
|
243
|
+
## 10. Symptom to cause
|
|
244
|
+
|
|
245
|
+
| Symptom | Likely cause |
|
|
246
|
+
| --- | --- |
|
|
247
|
+
| iOS Files previews the document instead of launching the app | `LSSupportsOpeningDocumentsInPlace` missing from the **root** of `Info.plist`, or nested inside `CFBundleDocumentTypes` |
|
|
248
|
+
| iOS Files lists the file's contents like a folder | Type conforms to `public.zip-archive` (or another archive type) instead of `public.data` |
|
|
249
|
+
| App is offered for unrelated files of other apps | Extension or MIME type too broad (`.zip`, `application/zip`); on Android, `mimeType="*/*"` with no `pathPattern` |
|
|
250
|
+
| App offers itself in its own share sheet | An `ACTION_SEND` filter that the app's own outbound flow matches |
|
|
251
|
+
| Android: file with several dots in its name never opens the app | `pathPattern` variants missing for the extra dots |
|
|
252
|
+
| Android: a `content://` file never opens the app | The URI path carries no filename — a limit of name-based matching, not a bug |
|
|
253
|
+
| iOS: the same document imports twice | The launch payload was read more than once; no "last handled" guard |
|
|
254
|
+
| *Missing Document Configuration* on App Store upload | `CFBundleDocumentTypes` declared without `LSSupportsOpeningDocumentsInPlace` or `UISupportsDocumentBrowser` |
|
|
255
|
+
| Document icon stays generic | App icon not final (the fallback derives from it), or the file manager ignores the declared icon |
|
|
256
|
+
|
|
257
|
+
## Sources
|
|
258
|
+
|
|
259
|
+
- Verified 2026-08-05 on a shipping Titanium 13.4.0 app: moving `LSSupportsOpeningDocumentsInPlace` from inside the `CFBundleDocumentTypes` dict to the root of the plist changed `plutil -extract` from *"No value at that key path"* to `true`, and changed Files behavior from previewing the document to launching the app.
|
|
260
|
+
- Verified on the same app: an exported type conforming to `public.zip-archive` made iOS Files list the archive contents; `public.data` did not.
|
|
261
|
+
- Verified with `cmd package query-activities`: a narrow extension plus `pathPattern` stops the app being offered for unrelated archives.
|
|
262
|
+
- Verified 2026-08-05 in the iOS Files app: with no icon keys declared, the document renders as a page with the app icon inside it.
|
|
263
|
+
- `Ti.App.iOS` `handleurl` event — see `ti-api/references/api-app-platform.md`.
|
|
264
|
+
- Apple, *Information Property List* reference, for `LSSupportsOpeningDocumentsInPlace`, `UIFileSharingEnabled` and `UISupportsDocumentBrowser`.
|
|
265
|
+
- Icon key names and sizes come from Apple's archived *Registering the File Types Your App Supports*; confirm sizes against current documentation before producing a full set.
|
|
@@ -30,21 +30,16 @@ Before migrating, evaluate the current codebase:
|
|
|
30
30
|
|
|
31
31
|
## Phase 1: styling-system organization
|
|
32
32
|
|
|
33
|
-
**Goal**: Migrate from inline and scattered styling into the styling system the
|
|
34
|
-
project actually uses.
|
|
33
|
+
**Goal**: Migrate from inline and scattered styling into the styling system the project actually uses.
|
|
35
34
|
|
|
36
35
|
### Step 0: detect PurgeTSS
|
|
37
36
|
|
|
38
37
|
Check for `purgetss/` or `purgetss/config.cjs` before editing styles.
|
|
39
38
|
|
|
40
|
-
- **PurgeTSS detected:** invoke the `purgetss` skill, use verified utilities and
|
|
41
|
-
|
|
42
|
-
generated `app/styles/app.tss`.
|
|
43
|
-
- **No PurgeTSS:** use standard per-controller TSS and a manually maintained
|
|
44
|
-
`app/styles/app.tss` for genuinely global selectors.
|
|
39
|
+
- **PurgeTSS detected:** invoke the `purgetss` skill, use verified utilities and semantic colors, configure reusable rules in `config.cjs`, and never edit the generated `app/styles/app.tss`.
|
|
40
|
+
- **No PurgeTSS:** use standard per-controller TSS and a manually maintained `app/styles/app.tss` for genuinely global selectors.
|
|
45
41
|
|
|
46
|
-
Do not migrate a PurgeTSS application back into manual TSS by following a generic
|
|
47
|
-
Alloy example.
|
|
42
|
+
Do not migrate a PurgeTSS application back into manual TSS by following a generic Alloy example.
|
|
48
43
|
|
|
49
44
|
### Step 1: audit existing styles
|
|
50
45
|
|
|
@@ -89,17 +84,13 @@ This step applies only when PurgeTSS is not present.
|
|
|
89
84
|
".btn-primary": { backgroundColor: '#2563eb', color: '#fff', height: 44, borderRadius: 8 }
|
|
90
85
|
```
|
|
91
86
|
|
|
92
|
-
In either styling path, replace hard-coded visual colors with semantic roles as
|
|
93
|
-
part of the migration. See [Theming and Dark Mode](theming.md).
|
|
87
|
+
In either styling path, replace hard-coded visual colors with semantic roles as part of the migration. See [Theming and Dark Mode](theming.md).
|
|
94
88
|
|
|
95
89
|
## Phase 1B: feedback surfaces
|
|
96
90
|
|
|
97
|
-
Inventory native alerts, option dialogs, Toasts, and local overlays. Classify
|
|
98
|
-
each occurrence before replacing it, then migrate one window at a time. Preserve
|
|
99
|
-
system-owned pickers, permission prompts, sharing, and biometric UI.
|
|
91
|
+
Inventory native alerts, option dialogs, Toasts, and local overlays. Classify each occurrence before replacing it, then migrate one window at a time. Preserve system-owned pickers, permission prompts, sharing, and biometric UI.
|
|
100
92
|
|
|
101
|
-
Use [Feedback Migration](feedback-migration.md) for the sequence, deliberate
|
|
102
|
-
silence rules, and verification contract.
|
|
93
|
+
Use [Feedback Migration](feedback-migration.md) for the sequence, deliberate silence rules, and verification contract.
|
|
103
94
|
|
|
104
95
|
## Phase 2: service layer extraction
|
|
105
96
|
|
|
@@ -323,8 +314,7 @@ For each phase:
|
|
|
323
314
|
3. **Test thoroughly** before moving to next feature
|
|
324
315
|
4. **Delete the replaced implementation** after the vertical slice passes
|
|
325
316
|
|
|
326
|
-
Use version control for rollback. Do not keep commented legacy implementations:
|
|
327
|
-
they conceal duplicate listeners, stale callbacks, and incomplete migrations.
|
|
317
|
+
Use version control for rollback. Do not keep commented legacy implementations: they conceal duplicate listeners, stale callbacks, and incomplete migrations.
|
|
328
318
|
|
|
329
319
|
## Common migration pitfalls
|
|
330
320
|
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Sharing content out
|
|
2
|
+
|
|
3
|
+
How to hand a link, a text or a file from your app to the rest of the system — the share sheet on iOS, the intent chooser on Android.
|
|
4
|
+
|
|
5
|
+
The two platforms are not symmetric and have not been for years, which is the single fact that explains every confusing implementation you will find in an older codebase. Android does this with the core SDK and no module. iOS does not: the SDK exposes no wrapper for `UIActivityViewController`, so a native module is still required. Code written before that split settled tends to route both platforms through the module, or through a cross-platform helper that only ever needed to exist for iOS.
|
|
6
|
+
|
|
7
|
+
This file is about sending content **out**. For receiving files — declaring a custom extension, UTIs, intent filters, `handleurl` — see [File type association](file-type-association.md).
|
|
8
|
+
|
|
9
|
+
## Contents
|
|
10
|
+
|
|
11
|
+
1. [The platform split](#1-the-platform-split)
|
|
12
|
+
2. [Android: the core SDK is enough](#2-android-the-core-sdk-is-enough)
|
|
13
|
+
3. [iOS: which module, and why one is still needed](#3-ios-which-module-and-why-one-is-still-needed)
|
|
14
|
+
4. [Text is not a file](#4-text-is-not-a-file)
|
|
15
|
+
5. [Do not pass text alongside a file](#5-do-not-pass-text-alongside-a-file)
|
|
16
|
+
6. [iPad needs an anchor](#6-ipad-needs-an-anchor)
|
|
17
|
+
7. [Knowing whether the user actually shared](#7-knowing-whether-the-user-actually-shared)
|
|
18
|
+
8. [Failing without an exception](#8-failing-without-an-exception)
|
|
19
|
+
9. [Verify instead of assuming](#9-verify-instead-of-assuming)
|
|
20
|
+
10. [Symptom to cause](#10-symptom-to-cause)
|
|
21
|
+
|
|
22
|
+
## 1. The platform split
|
|
23
|
+
|
|
24
|
+
| | Text or link | File |
|
|
25
|
+
| --- | --- | --- |
|
|
26
|
+
| **Android** | `Ti.Android.createIntent` + `createIntentChooser` | Same, with `putExtraUri` |
|
|
27
|
+
| **iOS** | Native module — no SDK API | Native module, or `DocumentViewer` if the type is previewable |
|
|
28
|
+
|
|
29
|
+
The asymmetry is historical, not architectural. Sharing on Titanium used to go through `com.alcoapps.socialshare`, a CommonJS wrapper by Ricardo Alcocer that split by platform and drove the same iOS module underneath. Once Android could open the system sheet directly, the Android branch stopped needing anything and the module became iOS-only — but the wrapper stayed in projects, and with it a layer of indirection that no longer buys anything.
|
|
30
|
+
|
|
31
|
+
**When you find one of those wrappers,** check what it still does. The usual finding is dead weight: version-comparison helpers, Facebook app-id variables, `image_blob` / `image_url` normalization for an argument shape nobody passes anymore. Adapting the old helper costs more than writing the two branches.
|
|
32
|
+
|
|
33
|
+
## 2. Android: the core SDK is enough
|
|
34
|
+
|
|
35
|
+
`Ti.Android.createIntent` and `Ti.Android.createIntentChooser` are core SDK APIs and are not deprecated. No module, no widget.
|
|
36
|
+
|
|
37
|
+
```javascript
|
|
38
|
+
function shareTextAndroid (text, chooserTitle) {
|
|
39
|
+
const activity = Ti.Android.rootActivity || Ti.Android.currentActivity
|
|
40
|
+
|
|
41
|
+
// No activity, no chooser to present. Calling startActivity on null throws in
|
|
42
|
+
// the face of whoever tapped Share.
|
|
43
|
+
if (!activity) {
|
|
44
|
+
return false
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const intent = Ti.Android.createIntent({
|
|
48
|
+
action: Ti.Android.ACTION_SEND,
|
|
49
|
+
type: 'text/plain'
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
intent.putExtra(Ti.Android.EXTRA_TEXT, text)
|
|
53
|
+
activity.startActivity(Ti.Android.createIntentChooser(intent, chooserTitle))
|
|
54
|
+
|
|
55
|
+
return true
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For a file, two things change and both are mandatory:
|
|
60
|
+
|
|
61
|
+
```javascript
|
|
62
|
+
intent.putExtraUri(Ti.Android.EXTRA_STREAM, file.nativePath)
|
|
63
|
+
intent.addFlags(Ti.Android.FLAG_GRANT_READ_URI_PERMISSION)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`putExtraUri` makes the SDK convert the path into a `content://` URI served by `TiFileProvider`, which ships declared in the merged manifest — nothing to configure. **Without `FLAG_GRANT_READ_URI_PERMISSION` the receiving app cannot read the file.** It is granted a URI it has no permission for, so the share appears to work and the other app shows an error or an empty attachment.
|
|
67
|
+
|
|
68
|
+
Set `type` to the real MIME type rather than `*/*`. An unknown type falls back to `application/octet-stream`, which offers every app on the device instead of the ones that can do something useful.
|
|
69
|
+
|
|
70
|
+
## 3. iOS: which module, and why one is still needed
|
|
71
|
+
|
|
72
|
+
There is no share sheet API in the SDK. This is verifiable rather than folklore — see [Verify instead of assuming](#9-verify-instead-of-assuming). The SDK offers `Ti.UI.iOS.DocumentViewer`, which is a previewer, and `Ti.App.iOS.UserActivity`, which is Handoff. Neither presents `UIActivityViewController`.
|
|
73
|
+
|
|
74
|
+
**The module is `dk.napp.social`**, and its provenance matters because the obvious repository is the wrong one:
|
|
75
|
+
|
|
76
|
+
| Repository | Status |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| [viezel/TiSocial.Framework](https://github.com/viezel/TiSocial.Framework) | **Archived April 2021.** Last release 2.0.0. This is the one search engines surface. |
|
|
79
|
+
| [hansemannn/TiSocial.Framework](https://github.com/hansemannn/TiSocial.Framework) | **The maintained fork.** Its 3.0.0 release states "Moved to this fork as the base repository". |
|
|
80
|
+
|
|
81
|
+
Releases on the fork: 3.0.0 (macOS support), 3.0.1 (fixes a possible iPad crash), 3.0.2 (restores iOS 12 compatibility). **3.0.2 is the highest published version.** If a project declares something higher in `tiapp.xml`, that build came from somewhere other than a release — treat the local copy as irreplaceable and say so in the project's install instructions, because nobody can download it.
|
|
82
|
+
|
|
83
|
+
```javascript
|
|
84
|
+
function shareTextIos (text, subject) {
|
|
85
|
+
const social = require('dk.napp.social')
|
|
86
|
+
|
|
87
|
+
if (!social.isActivityViewSupported()) {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
social.activityView({ subject: subject || '', text }, [])
|
|
92
|
+
return true
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Require the module inside the iOS branch, not at the top of the file. A module-level `require` runs on Android too, where it resolves to nothing useful.
|
|
97
|
+
|
|
98
|
+
## 4. Text is not a file
|
|
99
|
+
|
|
100
|
+
`DocumentViewer` takes the URL of a file; there is no way to hand it a string. So a file has two possible routes on iOS and a text has one.
|
|
101
|
+
|
|
102
|
+
**The file route via `DocumentViewer` works only when iOS can preview the type.** The viewer presents the document and its action button opens the real system sheet — no module involved. But "presents" is conditional: with a type the system cannot render, it presents nothing and reports no error. Silence, and the user sees a tap that did nothing.
|
|
103
|
+
|
|
104
|
+
So the decision is about the type, not about the file:
|
|
105
|
+
|
|
106
|
+
- **Type the system knows** (PDF, image, ZIP, plain text) → `DocumentViewer` is enough, and it saves you the module on this path.
|
|
107
|
+
- **Opaque type** — anything conforming to `public.data`, which is what a custom document type usually declares so that Files hands it over instead of previewing it (see [File type association](file-type-association.md)) → the viewer has nothing to show. Use the module's `activityView({ file })`.
|
|
108
|
+
|
|
109
|
+
This is the trap worth naming: **the same declaration that makes tapping the file open your app is what stops `DocumentViewer` from presenting it.** The two behaviors pull in opposite directions and both are correct.
|
|
110
|
+
|
|
111
|
+
## 5. Do not pass text alongside a file
|
|
112
|
+
|
|
113
|
+
When sharing a file, pass only the file. Adding `text` to the same payload makes iOS treat the string as a **second item** in the sheet, and "Save to Files" writes two files: the document, plus a `.txt` holding the caption.
|
|
114
|
+
|
|
115
|
+
```javascript
|
|
116
|
+
// wrong — "Save to Files" leaves the backup AND a stray text file
|
|
117
|
+
social.activityView({ file: file.nativePath, text: title }, [])
|
|
118
|
+
|
|
119
|
+
// right — the subject rides along without becoming an item
|
|
120
|
+
social.activityView({ file: file.nativePath, subject: title }, [])
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The symptom shows up only in destinations that write to disk. Sharing to a messaging app looks fine, which is why this survives testing.
|
|
124
|
+
|
|
125
|
+
## 6. iPad needs an anchor
|
|
126
|
+
|
|
127
|
+
On iPad the sheet is a popover and must be anchored to the view that was tapped, or it has nowhere to attach:
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
if (Ti.Platform.osname === 'ipad') {
|
|
131
|
+
options.view = sourceView
|
|
132
|
+
social.activityPopover(options, [])
|
|
133
|
+
return true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
social.activityView(options, [])
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
This means the controller has to pass the source view down to the sharing service — usually `event.source` from the tap handler. Services that take only a URL cannot support iPad without changing their signature, which is why the parameter is worth adding before the app ships on tablets.
|
|
140
|
+
|
|
141
|
+
## 7. Knowing whether the user actually shared
|
|
142
|
+
|
|
143
|
+
Both platforms open a sheet and return immediately. Neither the intent chooser nor a bare `activityView` call tells you what happened next.
|
|
144
|
+
|
|
145
|
+
On iOS the module emits it:
|
|
146
|
+
|
|
147
|
+
```javascript
|
|
148
|
+
const social = require('dk.napp.social')
|
|
149
|
+
|
|
150
|
+
social.addEventListener('complete', event => {
|
|
151
|
+
// event.activityName — where it went
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
social.addEventListener('cancelled', () => {})
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Register these **once**, at module scope inside the iOS branch, not per share — re-adding on every tap leaks listeners.
|
|
158
|
+
|
|
159
|
+
Android has no equivalent through the chooser. If the product needs to know, that asymmetry is the design constraint, and treating "sheet opened" as success is the honest fallback.
|
|
160
|
+
|
|
161
|
+
Skip all of this when nothing depends on the answer. An analytics event or a "Shared!" confirmation is a reason; symmetry with iOS is not.
|
|
162
|
+
|
|
163
|
+
## 8. Failing without an exception
|
|
164
|
+
|
|
165
|
+
Sharing fails for reasons that are not bugs: no activity, a device without sheet support, a file that was deleted between the tap and the call. **Return a boolean and let the caller decide whether to warn.** Throwing from a share function means an exception reaches whoever tapped a button.
|
|
166
|
+
|
|
167
|
+
```javascript
|
|
168
|
+
function share (payload, sourceView) {
|
|
169
|
+
try {
|
|
170
|
+
return OS_ANDROID
|
|
171
|
+
? shareAndroid(payload)
|
|
172
|
+
: shareIos(payload, sourceView)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
Ti.API.error(`[sharing] Could not share: ${error.message}`)
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The failure worth auditing in an existing codebase is a **mixed contract**: one branch returning `false` while its sibling throws or returns nothing. It usually means one path was hardened after a crash and the other was never revisited.
|
|
181
|
+
|
|
182
|
+
## 9. Verify instead of assuming
|
|
183
|
+
|
|
184
|
+
Whether the SDK exposes something is checkable in the installed SDK's API metadata, and takes less time than the argument about it:
|
|
185
|
+
|
|
186
|
+
```bash
|
|
187
|
+
JSCA=~/Library/Application\ Support/Titanium/mobilesdk/osx/<version>/api.jsca
|
|
188
|
+
|
|
189
|
+
python3 - "$JSCA" <<'PY'
|
|
190
|
+
import json, sys
|
|
191
|
+
d = json.load(open(sys.argv[1]))
|
|
192
|
+
for t in d.get('types', []):
|
|
193
|
+
if any(k in t['name'] for k in ('Activity', 'Share', 'Social', 'DocumentViewer')):
|
|
194
|
+
print(t['name'])
|
|
195
|
+
PY
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
On 13.4.0.GA this returns `Titanium.UI.iOS.DocumentViewer`, `Titanium.App.iOS.UserActivity`, `Titanium.UI.ActivityIndicator` and the Android `Activity` types — and nothing wrapping `UIActivityViewController`. The same query tells you whether an API you are about to rely on is marked deprecated.
|
|
199
|
+
|
|
200
|
+
For the module, check what is actually installed rather than what `tiapp.xml` claims:
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
ls ~/Library/Application\ Support/Titanium/modules/iphone/dk.napp.social/
|
|
204
|
+
cat ~/Library/Application\ Support/Titanium/modules/iphone/dk.napp.social/*/manifest
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
**Do not verify a suspicion by comparing against a sibling project.** Two codebases that share a lineage share its mistakes, so agreement between them proves nothing. The control is the platform documentation or the SDK metadata.
|
|
208
|
+
|
|
209
|
+
## 10. Symptom to cause
|
|
210
|
+
|
|
211
|
+
| Symptom | Cause |
|
|
212
|
+
| --- | --- |
|
|
213
|
+
| Tapping Share does nothing on iOS, no error | File type is not previewable and the code used `DocumentViewer`. Use the module for opaque types. |
|
|
214
|
+
| Receiving app shows an error or an empty attachment (Android) | Missing `FLAG_GRANT_READ_URI_PERMISSION`. |
|
|
215
|
+
| "Save to Files" writes two files | `text` passed alongside `file`. Use `subject`. |
|
|
216
|
+
| Sheet does not appear on iPad | `activityView` used instead of `activityPopover`, or no `view` anchor. |
|
|
217
|
+
| Crash when tapping Share on Android | `startActivity` on a null activity. Guard `rootActivity || currentActivity`. |
|
|
218
|
+
| Chooser offers every app on the device | `type` left as `*/*` or an unknown extension falling back to `application/octet-stream`. |
|
|
219
|
+
| `Couldn't find module: dk.napp.social` | Module not installed, or `require` at file scope running on Android. |
|
|
220
|
+
| Module version in `tiapp.xml` has no matching release | Locally built copy. It cannot be downloaded; document it as an install requirement. |
|
|
221
|
+
|
|
222
|
+
## Sources
|
|
223
|
+
|
|
224
|
+
- Titanium SDK 13.4.0.GA `api.jsca` — the absence of a share sheet API on iOS and the non-deprecated status of `Ti.Android.createIntentChooser`.
|
|
225
|
+
- [hansemannn/TiSocial.Framework](https://github.com/hansemannn/TiSocial.Framework) — maintained fork, releases 3.0.0 through 3.0.2.
|
|
226
|
+
- [viezel/TiSocial.Framework](https://github.com/viezel/TiSocial.Framework) — original, archived April 2021.
|
|
227
|
+
- [ricardoalcocer/socialshare](https://github.com/ricardoalcocer/socialshare) — the historical cross-platform wrapper, for recognizing it in old code.
|