Skip to content

Commit feb6c6f

Browse files
authored
Merge pull request #928 from GoogleChromeLabs/filehandling
Add file handling support
2 parents 70d7598 + 810c100 commit feb6c6f

File tree

12 files changed

+356
-0
lines changed

12 files changed

+356
-0
lines changed

packages/cli/README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,7 @@ Fields:
393393
|fullScopeUrl|string|false|The navigation scope that the browser considers to be within the app. If the user navigates outside the scope, it reverts to a normal web page inside a browser tab or window. Must be a full URL. Required and used only by Meta Quest devices.|
394394
|minSdkVersion|number|false|The minimum [Android API Level](https://developer.android.com/guide/topics/manifest/uses-sdk-element#ApiLevels) required for the application to run. Defaults to `23`, if `isMetaQuest` is `true`, and `19` otherwise.|
395395
|protocolHandlers|[ProtocolHandler](#protocolhandlers)[]|false|List of [Protocol Handlers](#protocolhandlers) supported by the app.|
396+
|fileHandlers|[FileHandler](#fileHandlers)[]|false|List of [File Hanlders](#fileHandlers) supported by the app.|
396397

397398
### Features
398399

@@ -473,6 +474,16 @@ List of Protocol Handlers registered for the application. These entries may not
473474
|protocol|string|true|Data scheme to register (e.g. `bitcoin`, `irc`, `web+coffee`).|
474475
|url|string|true|Formula for converting a custom data scheme back to a http(s) link, must include '%s' and be the same origin as the web manifest file. Example: `https://test.com/?target=%s`|
475476

477+
### FileHandlers
478+
479+
List of File Handlers registered for the application. These entries may not exactly match what was originally in the webmanifest. If a webmanifest entry is incorrect for any reason it will be ignored and a warning will be printed out. See [here](https://developer.chrome.com/docs/capabilities/web-apis/file-handling?hl=en) for more information about file handling and [here](https://wicg.github.io/manifest-incubations/#file_handlers-member) for file_handlers webmanifest spec.
480+
481+
|Name|Type|Required|Description|
482+
|:--:|:--:|:------:|:---------:|
483+
|actionUrl|string|true|URL to be navigated to in case of file handler launch matching the according MIME types|
484+
|mimeTypes|string[]|true|The list of MIME types for the file handler|
485+
486+
476487
## Manually setting up the Environment
477488

478489
### Get the Java Development Kit (JDK) 17.

packages/core/src/lib/TwaManifest.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {LocationDelegationConfig} from './features/LocationDelegationFeature';
2929
import {PlayBillingConfig} from './features/PlayBillingFeature';
3030
import {FirstRunFlagConfig} from './features/FirstRunFlagFeature';
3131
import {ArCoreConfig} from './features/ArCoreFeature';
32+
import {FileHandler, processFileHandlers} from './types/FileHandler';
3233

3334
// The minimum size needed for the app icon.
3435
const MIN_ICON_SIZE = 512;
@@ -171,6 +172,7 @@ export class TwaManifest {
171172
additionalTrustedOrigins: string[];
172173
retainedBundles: number[];
173174
protocolHandlers?: ProtocolHandler[];
175+
fileHandlers?: FileHandler[];
174176

175177
private static log = new ConsoleLog('twa-manifest');
176178

@@ -222,6 +224,7 @@ export class TwaManifest {
222224
this.additionalTrustedOrigins = data.additionalTrustedOrigins || [];
223225
this.retainedBundles = data.retainedBundles || [];
224226
this.protocolHandlers = data.protocolHandlers;
227+
this.fileHandlers = data.fileHandlers;
225228
}
226229

227230
/**
@@ -321,6 +324,12 @@ export class TwaManifest {
321324
fullScopeUrl,
322325
);
323326

327+
const fileHandlers = processFileHandlers(
328+
webManifest.file_handlers ?? [],
329+
fullStartUrl,
330+
fullScopeUrl,
331+
);
332+
324333
const twaManifest = new TwaManifest({
325334
packageId: generatePackageId(webManifestUrl.host) || '',
326335
host: webManifestUrl.host,
@@ -353,6 +362,7 @@ export class TwaManifest {
353362
orientation: asOrientation(webManifest.orientation) || DEFAULT_ORIENTATION,
354363
fullScopeUrl: fullScopeUrl.toString(),
355364
protocolHandlers: processedProtocolHandlers,
365+
fileHandlers,
356366
});
357367
return twaManifest;
358368
}
@@ -505,6 +515,18 @@ export class TwaManifest {
505515
const fullStartUrl: URL = new URL(webManifest['start_url'] || '/', webManifestUrl);
506516
const fullScopeUrl: URL = new URL(webManifest['scope'] || '.', webManifestUrl);
507517

518+
let fileHandlers = oldTwaManifestJson.fileHandlers;
519+
if (!(fieldsToIgnore.includes('file_handlers'))) {
520+
fileHandlers = processFileHandlers(
521+
webManifest.file_handlers ?? [],
522+
fullStartUrl,
523+
fullScopeUrl,
524+
);
525+
if (fileHandlers.length == 0) {
526+
fileHandlers = oldTwaManifestJson.fileHandlers;
527+
}
528+
}
529+
508530
const twaManifest = new TwaManifest({
509531
...oldTwaManifestJson,
510532
name: this.getNewFieldValue('name', fieldsToIgnore, oldTwaManifest.name,
@@ -527,6 +549,7 @@ export class TwaManifest {
527549
monochromeIconUrl: monochromeIconUrl || oldTwaManifestJson.monochromeIconUrl,
528550
shortcuts: shortcuts,
529551
protocolHandlers: protocolHandlers,
552+
fileHandlers,
530553
});
531554
return twaManifest;
532555
}
@@ -583,6 +606,7 @@ export interface TwaManifestJson {
583606
additionalTrustedOrigins?: string[];
584607
retainedBundles?: number[];
585608
protocolHandlers?: ProtocolHandler[];
609+
fileHandlers?: FileHandler[];
586610
}
587611

588612
export interface SigningKeyInfo {

packages/core/src/lib/features/EmptyFeature.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ export class EmptyFeature implements Feature {
2121
buildGradle: {
2222
repositories: string[];
2323
dependencies: string[];
24+
configs: string[];
2425
} = {
2526
repositories: new Array<string>(),
2627
dependencies: new Array<string>(),
28+
configs: new Array<string>(),
2729
};
2830

2931
androidManifest: {

packages/core/src/lib/features/Feature.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export interface Feature {
4141
* Example `androidx.appcompat:appcompat:1.2.0`.
4242
*/
4343
dependencies: string[];
44+
/**
45+
* Entries to be added the `android.defaultConfig` section.
46+
*/
47+
configs: string[];
4448
};
4549

4650
/**

packages/core/src/lib/features/FeatureManager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {FirstRunFlagFeature} from './FirstRunFlagFeature';
2323
import {Log, ConsoleLog} from '../Log';
2424
import {ArCoreFeature} from './ArCoreFeature';
2525
import {ProtocolHandlersFeature} from './ProtocolHandlersFeature';
26+
import {FileHandlingFeature} from './FileHandlingFeature';
2627

2728
const ANDROID_BROWSER_HELPER_VERSIONS = {
2829
stable: 'com.google.androidbrowserhelper:androidbrowserhelper:2.6.0',
@@ -37,6 +38,7 @@ export class FeatureManager {
3738
buildGradle = {
3839
repositories: new Set<string>(),
3940
dependencies: new Set<string>(),
41+
configs: new Set<string>(),
4042
};
4143
androidManifest = {
4244
permissions: new Set<string>(),
@@ -108,6 +110,10 @@ export class FeatureManager {
108110
if (twaManifest.protocolHandlers) {
109111
this.addFeature(new ProtocolHandlersFeature(twaManifest.protocolHandlers));
110112
}
113+
114+
if (twaManifest.fileHandlers) {
115+
this.addFeature(new FileHandlingFeature(twaManifest.fileHandlers));
116+
}
111117
}
112118

113119
private addFeature(feature: Feature): void {
@@ -120,6 +126,10 @@ export class FeatureManager {
120126
this.buildGradle.dependencies.add(dep);
121127
});
122128

129+
feature.buildGradle.configs.forEach((dep) => {
130+
this.buildGradle.configs.add(dep);
131+
});
132+
123133
// Adds properties to application.
124134
feature.applicationClass.imports.forEach((imp) => {
125135
this.applicationClass.imports.add(imp);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/*
2+
* Copyright 2025 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import {EmptyFeature} from './EmptyFeature';
18+
import {FileHandler} from '../types/FileHandler';
19+
20+
const activityAliasTemplate = (handler: FileHandler, index: number): string => `
21+
<activity-alias
22+
android:name="FileHandlingActivity${index}"
23+
android:targetActivity="LauncherActivity"
24+
android:exported="true">
25+
<meta-data android:name="android.support.customtabs.trusted.FILE_HANDLING_ACTION_URL"
26+
android:value="@string/fileHandlingActionUrl${index}" />
27+
<intent-filter>
28+
<action android:name="android.intent.action.VIEW"/>
29+
<category android:name="android.intent.category.DEFAULT" />
30+
<category android:name="android.intent.category.BROWSABLE"/>
31+
<data android:scheme="content" />
32+
${ handler.mimeTypes.map((mimeType: string) => `
33+
<data android:mimeType="${mimeType}" />`,
34+
).join('') }
35+
</intent-filter>
36+
</activity-alias>
37+
`;
38+
39+
export class FileHandlingFeature extends EmptyFeature {
40+
constructor(fileHandlers: FileHandler[]) {
41+
super('fileHandling');
42+
if (fileHandlers.length === 0) return;
43+
for (let i = 0; i < fileHandlers.length; i++) {
44+
this.androidManifest.components.push(activityAliasTemplate(fileHandlers[i], i));
45+
this.buildGradle.configs.push(
46+
`resValue "string", "fileHandlingActionUrl${i}", "${fileHandlers[i].actionUrl}"`);
47+
}
48+
}
49+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright 2025 Google Inc. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export interface FileHandlerJson {
18+
action?: string;
19+
accept?: {
20+
[mimeType: string]: Array<string>;
21+
};
22+
}
23+
24+
export interface FileHandler {
25+
actionUrl: string;
26+
mimeTypes: Array<string>;
27+
}
28+
29+
function normalizeUrl(url: string, startUrl: URL, scopeUrl: URL): string | undefined {
30+
try {
31+
const absoluteUrl = new URL(url, startUrl);
32+
33+
if (absoluteUrl.protocol !== 'https:') {
34+
console.warn('Ignoring url with illegal scheme:', absoluteUrl.toString());
35+
return;
36+
}
37+
38+
if (absoluteUrl.origin != scopeUrl.origin) {
39+
console.warn('Ignoring url with invalid origin:', absoluteUrl.toString());
40+
return;
41+
}
42+
43+
if (!absoluteUrl.pathname.startsWith(scopeUrl.pathname)) {
44+
console.warn('Ignoring url not within manifest scope: ', absoluteUrl.toString());
45+
return;
46+
}
47+
48+
return absoluteUrl.toString();
49+
} catch (error) {
50+
console.warn('Ignoring invalid url:', url);
51+
}
52+
}
53+
54+
export function processFileHandlers(
55+
fileHandlers: FileHandlerJson[],
56+
startUrl: URL,
57+
scopeUrl: URL,
58+
): FileHandler[] {
59+
const processedFileHandlers: FileHandler[] = [];
60+
61+
for (const handler of fileHandlers) {
62+
if (!handler.action || !handler.accept) continue;
63+
64+
const actionUrl = normalizeUrl(handler.action, startUrl, scopeUrl);
65+
if (!actionUrl) continue;
66+
67+
const mimeTypes = Object.keys(handler.accept);
68+
if (mimeTypes.length == 0) continue;
69+
70+
const processedHandler: FileHandler = {
71+
actionUrl,
72+
mimeTypes,
73+
};
74+
75+
processedFileHandlers.push(processedHandler);
76+
}
77+
78+
return processedFileHandlers;
79+
}

packages/core/src/lib/types/WebManifest.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
* limitations under the License.
1616
*/
1717

18+
import {FileHandlerJson} from './FileHandler';
1819
import {ProtocolHandler} from './ProtocolHandler';
1920

2021
export interface WebManifestIcon {
@@ -70,4 +71,5 @@ export interface WebManifestJson {
7071
share_target?: ShareTarget;
7172
orientation?: OrientationLock;
7273
protocol_handlers?: Array<ProtocolHandler>;
74+
file_handlers?: Array<FileHandlerJson>;
7375
}

0 commit comments

Comments
 (0)