Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DO NOT MERGE YET Protocol Handler support #911

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ Fields:
|webManifestUrl|string|false|Full URL to the PWA Web Manifest. Required for the application to be compatible with Chrome OS and Meta Quest devices.|
|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.|
|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.|
|protocolHandlers|[ProtocolHandler](#protocolhandlers)[]|false|List of [Protocol Handlers](#protocolhandlers) supported by the app.|

### Features

Expand Down Expand Up @@ -462,6 +463,16 @@ Information on the signature fingerprints for the application. Use to generate t
|name|string|false|An optional name for the fingerprint.|
|value|string|true|The SHA-256 value for the fingerprint.|


### ProtocolHandlers

List of Protocol Handlers registered for the application. These entries may not exactly match what was originally in the webmanifest, because they have been normalized and validated using [these](https://wicg.github.io/manifest-incubations/#processing-the-protocol_handlers-member) rules. If a webmanifest entry is incorrect for any reason (invalid protocol, malformed target url, missing '%s' token) they will be ignored and a warning will be printed out. See [here](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/protocol_handlers) for more information about the Protocol Handler spec. The full list of supported protocols is [here](https://github.com/GoogleChromeLabs/bubblewrap/blob/main/packages/core/src/lib/types/ProtocolHandler.ts).

|Name|Type|Required|Description|
|:--:|:--:|:------:|:---------:|
|protocol|string|true|Data scheme to register (e.g. `bitcoin`, `irc`, `web+coffee`).|
|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`|

## Manually setting up the Environment

### Get the Java Development Kit (JDK) 17.
Expand Down
25 changes: 25 additions & 0 deletions packages/core/src/lib/TwaManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {findSuitableIcon, generatePackageId, validateNotEmpty} from './util';
import Color = require('color');
import {ConsoleLog} from './Log';
import {ShareTarget, WebManifestIcon, WebManifestJson} from './types/WebManifest';
import {processProtocolHandlers, ProtocolHandler} from './types/ProtocolHandler';
import {ShortcutInfo} from './ShortcutInfo';
import {AppsFlyerConfig} from './features/AppsFlyerFeature';
import {LocationDelegationConfig} from './features/LocationDelegationFeature';
Expand Down Expand Up @@ -169,6 +170,7 @@ export class TwaManifest {
serviceAccountJsonFile: string | undefined;
additionalTrustedOrigins: string[];
retainedBundles: number[];
protocolHandlers?: ProtocolHandler[];

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

Expand Down Expand Up @@ -219,6 +221,7 @@ export class TwaManifest {
this.serviceAccountJsonFile = data.serviceAccountJsonFile;
this.additionalTrustedOrigins = data.additionalTrustedOrigins || [];
this.retainedBundles = data.retainedBundles || [];
this.protocolHandlers = data.protocolHandlers;
}

/**
Expand Down Expand Up @@ -312,6 +315,12 @@ export class TwaManifest {
return icon ? new URL(icon.src, webManifestUrl).toString() : undefined;
}

const processedProtocolHandlers = processProtocolHandlers(
webManifest.protocol_handlers ?? [],
fullStartUrl,
fullScopeUrl,
);

const twaManifest = new TwaManifest({
packageId: generatePackageId(webManifestUrl.host) || '',
host: webManifestUrl.host,
Expand Down Expand Up @@ -343,6 +352,7 @@ export class TwaManifest {
shareTarget: TwaManifest.verifyShareTarget(webManifestUrl, webManifest.share_target),
orientation: asOrientation(webManifest.orientation) || DEFAULT_ORIENTATION,
fullScopeUrl: fullScopeUrl.toString(),
protocolHandlers: processedProtocolHandlers,
});
return twaManifest;
}
Expand Down Expand Up @@ -479,6 +489,19 @@ export class TwaManifest {
oldTwaManifestJson.iconUrl!, webManifest.icons!, 'monochrome', MIN_NOTIFICATION_ICON_SIZE,
webManifestUrl);

const protocolHandlersMap = new Map<string, string>();
for (const handler of oldTwaManifest.protocolHandlers ?? []) {
protocolHandlersMap.set(handler.protocol, handler.url);
}
if (!(fieldsToIgnore.includes('protocol_handlers'))) {
for (const handler of webManifest.protocol_handlers ?? []) {
protocolHandlersMap.set(handler.protocol, handler.url);
};
}
const protocolHandlers = Array.from(protocolHandlersMap.entries()).map(([protocol, url]) => {
return {protocol, url} as ProtocolHandler;
});

const fullStartUrl: URL = new URL(webManifest['start_url'] || '/', webManifestUrl);
const fullScopeUrl: URL = new URL(webManifest['scope'] || '.', webManifestUrl);

Expand All @@ -503,6 +526,7 @@ export class TwaManifest {
maskableIconUrl: maskableIconUrl || oldTwaManifestJson.maskableIconUrl,
monochromeIconUrl: monochromeIconUrl || oldTwaManifestJson.monochromeIconUrl,
shortcuts: shortcuts,
protocolHandlers: protocolHandlers,
});
return twaManifest;
}
Expand Down Expand Up @@ -558,6 +582,7 @@ export interface TwaManifestJson {
serviceAccountJsonFile?: string;
additionalTrustedOrigins?: string[];
retainedBundles?: number[];
protocolHandlers?: ProtocolHandler[];
}

export interface SigningKeyInfo {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/lib/features/EmptyFeature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,12 @@ export class EmptyFeature implements Feature {
permissions: string[];
components: string[];
applicationMetadata: Metadata[];
launcherActivityEntries: string[];
} = {
permissions: new Array<string>(),
components: new Array<string>(),
applicationMetadata: new Array<Metadata>(),
launcherActivityEntries: new Array<string>(),
};

applicationClass: {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/lib/features/Feature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ export interface Feature {
* Additional meta-data items to be added into the `application` tag.
*/
applicationMetadata: Metadata[];
/**
* Additional manifest entries to be added into the `activity` tag of LauncherActivity.
*/
launcherActivityEntries: string[];
};
/**
* Customizations to be added to `app/src/main/java/<app-package>/Application.java`.
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/lib/features/FeatureManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {TwaManifest} from '../TwaManifest';
import {FirstRunFlagFeature} from './FirstRunFlagFeature';
import {Log, ConsoleLog} from '../Log';
import {ArCoreFeature} from './ArCoreFeature';
import {ProtocolHandlersFeature} from './ProtocolHandlersFeature';

const ANDROID_BROWSER_HELPER_VERSIONS = {
stable: 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0',
Expand All @@ -41,6 +42,7 @@ export class FeatureManager {
permissions: new Set<string>(),
components: new Array<string>(),
applicationMetadata: new Array<Metadata>(),
launcherActivityEntries: new Array<string>(),
};
applicationClass = {
imports: new Set<string>(),
Expand Down Expand Up @@ -102,6 +104,10 @@ export class FeatureManager {
if (twaManifest.enableNotifications) {
this.androidManifest.permissions.add('android.permission.POST_NOTIFICATIONS');
}

if (twaManifest.protocolHandlers) {
this.addFeature(new ProtocolHandlersFeature(twaManifest.protocolHandlers));
}
}

private addFeature(feature: Feature): void {
Expand Down
52 changes: 52 additions & 0 deletions packages/core/src/lib/features/ProtocolHandlersFeature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {EmptyFeature} from './EmptyFeature';
import {ProtocolHandler} from '../types/ProtocolHandler';

export class ProtocolHandlersFeature extends EmptyFeature {
constructor(protocolHandlers: ProtocolHandler[]) {
super('protocolHandlers');
if (protocolHandlers.length === 0) return;
for (const handler of protocolHandlers) {
this.androidManifest.launcherActivityEntries.push(
`<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="${handler.protocol}" />
</intent-filter>`,
);
}
this.launcherActivity.imports.push(
'java.util.HashMap',
'java.util.Map',
);
const mapEntries = new Array<string>();
for (const handler of protocolHandlers) {
mapEntries.push(
`registry.put("${handler.protocol}", Uri.parse("${handler.url}"));`,
);
}
this.launcherActivity.methods.push(
`@Override
protected Map<String, Uri> getProtocolHandlers() {
Map<String, Uri> registry = new HashMap<>();
${mapEntries.join('\n')}
return registry;
}`);
}
}
115 changes: 115 additions & 0 deletions packages/core/src/lib/types/ProtocolHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
/*
* Copyright 2025 Google Inc. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

export interface ProtocolHandler {
protocol: string;
url: string;
}

const ProtocolHandlerExtraScheme = /^web\+[a-z]+$/;
const ProtcolHandlerAllowedSchemes = [
'bitcoin', 'ftp', 'ftps', 'geo', 'im', 'irc', 'ircs',
'magnet', 'mailto', 'matrix', 'news', 'nntp', 'openpgp4fpr',
'sftp', 'sip', 'ssh', 'urn', 'webcal', 'wtai', 'xmpp',
];
// 'mms', 'sms', 'smsto', and 'tel' are not supported!

function normalizeProtocol(protocol: string): string | undefined {
const normalized = protocol.toLowerCase();

if (ProtcolHandlerAllowedSchemes.includes(normalized)) {
return normalized;
}

if (ProtocolHandlerExtraScheme.test(normalized)) {
return normalized;
}

console.warn('Ignoring invalid protocol:', protocol);
return undefined;
}

function normalizeUrl(url: string, startUrl: URL, scopeUrl: URL): string | undefined {
if (!url.includes('%s')) {
console.warn('Ignoring url without %%s:', url);
return undefined;
}

try {
const absoluteUrl = new URL(url);

if (absoluteUrl.protocol !== 'https:') {
console.warn('Ignoring absolute url with illegal scheme:', absoluteUrl.toString());
return undefined;
}

if (absoluteUrl.origin != scopeUrl.origin) {
console.warn('Ignoring absolute url with invalid origin:', absoluteUrl.toString());
return undefined;
}

if (!absoluteUrl.pathname.startsWith(scopeUrl.pathname)) {
console.warn('Ignoring absolute url not within manifest scope: ', absoluteUrl.toString());
return undefined;
}

return absoluteUrl.toString();
} catch (error) {
// Expected, url might be relative!
}

try {
const relativeUrl = new URL(url, startUrl);
return relativeUrl.toString();
} catch (error) {
console.warn('Ignoring invalid relative url:', url);
}
}

export function processProtocolHandlers(
protocolHandlers: ProtocolHandler[],
startUrl: URL,
scopeUrl: URL,
): ProtocolHandler[] {
const processedProtocolHandlers: ProtocolHandler[] = [];

for (const handler of protocolHandlers) {
if (!handler.protocol || !handler.url) continue;

const normalizedProtocol = normalizeProtocol(handler.protocol);
const normalizedUrl = normalizeUrl(handler.url, startUrl, scopeUrl);

if (!normalizedProtocol || !normalizedUrl) {
continue;
}

processedProtocolHandlers.push({protocol: normalizedProtocol, url: normalizedUrl});
}

return processedProtocolHandlers;
}

export function normalizeProtocolForTesting(protocol: string): string | undefined {
return normalizeProtocol(protocol);
}

export function normalizeUrlForTesting(
url: string,
startUrl: URL,
scopeUrl: URL,
): string | undefined {
return normalizeUrl(url, startUrl, scopeUrl);
}
3 changes: 3 additions & 0 deletions packages/core/src/lib/types/WebManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
* limitations under the License.
*/

import {ProtocolHandler} from './ProtocolHandler';

export interface WebManifestIcon {
src: string;
sizes?: string;
Expand Down Expand Up @@ -67,4 +69,5 @@ export interface WebManifestJson {
shortcuts?: Array<WebManifestShortcutJson>;
share_target?: ShareTarget;
orientation?: OrientationLock;
protocol_handlers?: Array<ProtocolHandler>;
}
Loading