From 13cccb7de267459f384703febf7d06470f097bd3 Mon Sep 17 00:00:00 2001 From: Greg Stepniewski Date: Fri, 7 Mar 2025 12:33:01 +0000 Subject: [PATCH] Protocol Handler support --- packages/cli/README.md | 11 ++ packages/core/src/lib/TwaManifest.ts | 25 +++ .../core/src/lib/features/EmptyFeature.ts | 2 + packages/core/src/lib/features/Feature.ts | 4 + .../core/src/lib/features/FeatureManager.ts | 6 + .../lib/features/ProtocolHandlersFeature.ts | 52 +++++++ .../core/src/lib/types/ProtocolHandler.ts | 115 ++++++++++++++ packages/core/src/lib/types/WebManifest.ts | 3 + .../core/src/spec/lib/ProtocolHandlerSpec.ts | 143 ++++++++++++++++++ packages/core/src/spec/lib/TwaManifestSpec.ts | 46 +++++- .../app/src/main/AndroidManifest.xml | 4 + 11 files changed, 409 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/lib/features/ProtocolHandlersFeature.ts create mode 100644 packages/core/src/lib/types/ProtocolHandler.ts create mode 100644 packages/core/src/spec/lib/ProtocolHandlerSpec.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 71899c70..e9b95739 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 @@ -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. diff --git a/packages/core/src/lib/TwaManifest.ts b/packages/core/src/lib/TwaManifest.ts index bb672afe..13c0f36e 100644 --- a/packages/core/src/lib/TwaManifest.ts +++ b/packages/core/src/lib/TwaManifest.ts @@ -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'; @@ -169,6 +170,7 @@ export class TwaManifest { serviceAccountJsonFile: string | undefined; additionalTrustedOrigins: string[]; retainedBundles: number[]; + protocolHandlers?: ProtocolHandler[]; private static log = new ConsoleLog('twa-manifest'); @@ -219,6 +221,7 @@ export class TwaManifest { this.serviceAccountJsonFile = data.serviceAccountJsonFile; this.additionalTrustedOrigins = data.additionalTrustedOrigins || []; this.retainedBundles = data.retainedBundles || []; + this.protocolHandlers = data.protocolHandlers; } /** @@ -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, @@ -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; } @@ -479,6 +489,19 @@ export class TwaManifest { oldTwaManifestJson.iconUrl!, webManifest.icons!, 'monochrome', MIN_NOTIFICATION_ICON_SIZE, webManifestUrl); + const protocolHandlersMap = new Map(); + 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); @@ -503,6 +526,7 @@ export class TwaManifest { maskableIconUrl: maskableIconUrl || oldTwaManifestJson.maskableIconUrl, monochromeIconUrl: monochromeIconUrl || oldTwaManifestJson.monochromeIconUrl, shortcuts: shortcuts, + protocolHandlers: protocolHandlers, }); return twaManifest; } @@ -558,6 +582,7 @@ export interface TwaManifestJson { serviceAccountJsonFile?: string; additionalTrustedOrigins?: string[]; retainedBundles?: number[]; + protocolHandlers?: ProtocolHandler[]; } export interface SigningKeyInfo { diff --git a/packages/core/src/lib/features/EmptyFeature.ts b/packages/core/src/lib/features/EmptyFeature.ts index 3567bb3d..291c4601 100644 --- a/packages/core/src/lib/features/EmptyFeature.ts +++ b/packages/core/src/lib/features/EmptyFeature.ts @@ -30,10 +30,12 @@ export class EmptyFeature implements Feature { permissions: string[]; components: string[]; applicationMetadata: Metadata[]; + launcherActivityEntries: string[]; } = { permissions: new Array(), components: new Array(), applicationMetadata: new Array(), + launcherActivityEntries: new Array(), }; applicationClass: { diff --git a/packages/core/src/lib/features/Feature.ts b/packages/core/src/lib/features/Feature.ts index 3ffb5a33..7aa54a23 100644 --- a/packages/core/src/lib/features/Feature.ts +++ b/packages/core/src/lib/features/Feature.ts @@ -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//Application.java`. diff --git a/packages/core/src/lib/features/FeatureManager.ts b/packages/core/src/lib/features/FeatureManager.ts index 4ef441b4..29d62a7c 100644 --- a/packages/core/src/lib/features/FeatureManager.ts +++ b/packages/core/src/lib/features/FeatureManager.ts @@ -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', @@ -41,6 +42,7 @@ export class FeatureManager { permissions: new Set(), components: new Array(), applicationMetadata: new Array(), + launcherActivityEntries: new Array(), }; applicationClass = { imports: new Set(), @@ -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 { diff --git a/packages/core/src/lib/features/ProtocolHandlersFeature.ts b/packages/core/src/lib/features/ProtocolHandlersFeature.ts new file mode 100644 index 00000000..dbd2c023 --- /dev/null +++ b/packages/core/src/lib/features/ProtocolHandlersFeature.ts @@ -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( + ` + + + + + `, + ); + } + this.launcherActivity.imports.push( + 'java.util.HashMap', + 'java.util.Map', + ); + const mapEntries = new Array(); + for (const handler of protocolHandlers) { + mapEntries.push( + `registry.put("${handler.protocol}", Uri.parse("${handler.url}"));`, + ); + } + this.launcherActivity.methods.push( + `@Override + protected Map getProtocolHandlers() { + Map registry = new HashMap<>(); + ${mapEntries.join('\n')} + return registry; + }`); + } +} diff --git a/packages/core/src/lib/types/ProtocolHandler.ts b/packages/core/src/lib/types/ProtocolHandler.ts new file mode 100644 index 00000000..3026c1a7 --- /dev/null +++ b/packages/core/src/lib/types/ProtocolHandler.ts @@ -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); +} diff --git a/packages/core/src/lib/types/WebManifest.ts b/packages/core/src/lib/types/WebManifest.ts index 6d9f2bdc..3e612bb9 100644 --- a/packages/core/src/lib/types/WebManifest.ts +++ b/packages/core/src/lib/types/WebManifest.ts @@ -15,6 +15,8 @@ * limitations under the License. */ +import {ProtocolHandler} from './ProtocolHandler'; + export interface WebManifestIcon { src: string; sizes?: string; @@ -67,4 +69,5 @@ export interface WebManifestJson { shortcuts?: Array; share_target?: ShareTarget; orientation?: OrientationLock; + protocol_handlers?: Array; } diff --git a/packages/core/src/spec/lib/ProtocolHandlerSpec.ts b/packages/core/src/spec/lib/ProtocolHandlerSpec.ts new file mode 100644 index 00000000..6a7a969b --- /dev/null +++ b/packages/core/src/spec/lib/ProtocolHandlerSpec.ts @@ -0,0 +1,143 @@ +/* +* 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 { + normalizeProtocolForTesting, + normalizeUrlForTesting, + processProtocolHandlers, + ProtocolHandler, +} from '../../lib/types/ProtocolHandler'; + +describe('ProtocolHandler', () => { + describe('#normalizeProtocol', () => { + it('Accepts allowed schemes', () => { + let normalized = normalizeProtocolForTesting('bitcoin'); + expect(normalized).toBe('bitcoin'); + normalized = normalizeProtocolForTesting('XMPP'); + expect(normalized).toBe('xmpp'); + }); + it('Rejects not allowed schemes', () => { + const normalized = normalizeProtocolForTesting('something-else'); + expect(normalized).toBeUndefined(); + }); + it('Allows web+ schemes', () => { + let normalized = normalizeProtocolForTesting('web+tea'); + expect(normalized).toBe('web+tea'); + normalized = normalizeProtocolForTesting('web+Coffee'); + expect(normalized).toBe('web+coffee'); + }); + it('Rejects invalid web+ schemes', () => { + let normalized = normalizeProtocolForTesting('web+'); + expect(normalized).toBeUndefined(); + normalized = normalizeProtocolForTesting('web+a-b'); + expect(normalized).toBeUndefined(); + normalized = normalizeProtocolForTesting('web+mailto:'); + expect(normalized).toBeUndefined(); + }); + }); + + describe('#normalizeUrl', () => { + it('Accepts a valid relative url format', () => { + const normalized = normalizeUrlForTesting( + '?coffee=%s', + new URL('https://test.com/app/start'), + new URL('https://test.com/app/'), + ); + expect(normalized).toBe('https://test.com/app/start?coffee=%s'); + }); + it('Accepts a valid absolute url format', () => { + const normalized = normalizeUrlForTesting( + 'https://test.com/app/start?tea=%s', + new URL('https://test.com/app/start'), + new URL('https://test.com/app/'), + ); + expect(normalized).toBe('https://test.com/app/start?tea=%s'); + }); + it('Rejects url format without %s', () => { + const normalized = normalizeUrlForTesting( + 'coffee', + new URL('https://test.com/app/start'), + new URL('https://test.com/app/'), + ); + expect(normalized).toBeUndefined(); + }); + it('Rejects absolute url with different origin', () => { + const normalized = normalizeUrlForTesting( + 'https://fail.com/?tea=%s', + new URL('https://test.com/app/start'), + new URL('https://test.com/app/'), + ); + expect(normalized).toBeUndefined(); + }); + it('Rejects absolute url outside of scope', () => { + const normalized = normalizeUrlForTesting( + 'https://test.com/notapp?tea=%s', + new URL('https://test.com/app/start'), + new URL('https://test.com/app/'), + ); + expect(normalized).toBeUndefined(); + }); + it('Rejects absolute url with illegal scheme', () => { + const normalized = normalizeUrlForTesting( + 'vnc://test.com/?tea=%s', + new URL('https://test.com/app/start'), + new URL('https://test.com/app/'), + ); + expect(normalized).toBeUndefined(); + }); + it('Rejects absolute url with http scheme', () => { + const normalized = normalizeUrlForTesting( + 'http://test.com/?tea=%s', + new URL('https://test.com/app/start'), + new URL('https://test.com/app/'), + ); + expect(normalized).toBeUndefined(); + }); + }); + + describe('#processProtocolHandlers', () => { + it('Accepts valid protocol handlers', () => { + const startUrl = new URL('https://test.com/app/start'); + const scopeUrl = new URL('https://test.com/app/'); + const testHandlers: ProtocolHandler[] = [ + {protocol: 'bitcoin', url: '?wallet=%s'}, + {protocol: 'XMPP', url: `${startUrl}?contact=%s`}, + {protocol: 'web+tea', url: '?tea=%s'}, + {protocol: 'web+HEY', url: '?there=%s'}, + ]; + const expectedHandlers: ProtocolHandler[] = [ + {protocol: 'bitcoin', url: `${startUrl}?wallet=%s`}, + {protocol: 'xmpp', url: `${startUrl}?contact=%s`}, + {protocol: 'web+tea', url: `${startUrl}?tea=%s`}, + {protocol: 'web+hey', url: `${startUrl}?there=%s`}, + ]; + const processedHandlers = processProtocolHandlers(testHandlers, startUrl, scopeUrl); + expect(processedHandlers).toEqual(expectedHandlers); + }); + it('Rejects invalid protocol handlers', () => { + const startUrl = new URL('https://test.com/app/start'); + const scopeUrl = new URL('https://test.com/app/'); + const testHandlers: ProtocolHandler[] = [ + {protocol: 'coffee', url: '?wallet=%s'}, + {protocol: 'web+tea', url: `${startUrl}?contact`}, + {protocol: 'web+tea', url: '?tea'}, + {protocol: 'web', url: '?there=%s'}, + ]; + const processedHandlers = processProtocolHandlers(testHandlers, startUrl, scopeUrl); + expect(processedHandlers).toEqual([]); + }); + }); +}); diff --git a/packages/core/src/spec/lib/TwaManifestSpec.ts b/packages/core/src/spec/lib/TwaManifestSpec.ts index 2b4dbe4b..eb9e21de 100644 --- a/packages/core/src/spec/lib/TwaManifestSpec.ts +++ b/packages/core/src/spec/lib/TwaManifestSpec.ts @@ -354,6 +354,10 @@ describe('TwaManifest', () => { 'purpose': 'any', }, ], + 'protocol_handlers': [{ + 'protocol': 'web+test-replace', + 'url': 'test-format-web/%s', + }], }; const twaManifest = new TwaManifest({ 'packageId': 'id', @@ -390,12 +394,32 @@ describe('TwaManifest', () => { 'fullScopeUrl': 'https://name.github.io/', 'appVersion': '1', 'serviceAccountJsonFile': '/home/service-account.json', + 'protocolHandlers': [ + { + 'protocol': 'web+test-replace', + 'url': 'test-format-twa/%s', + }, + { + 'protocol': 'web+test-keep', + 'url': 'test-format-twa/%s', + }, + ], }); // The versions shouldn't change because the update happens in `cli`. const expectedTwaManifest = new TwaManifest({ ...twaManifest.toJson(), 'launcherName': 'different_name', 'display': 'fullscreen', + 'protocolHandlers': [ + { + 'protocol': 'web+test-replace', + 'url': 'test-format-web/%s', + }, + { + 'protocol': 'web+test-keep', + 'url': 'test-format-twa/%s', + }, + ], }); // A URL to insert as the webManifestUrl. const url = new URL('https://name.github.io/manifest.json'); @@ -415,6 +439,10 @@ describe('TwaManifest', () => { 'purpose': 'any', }, ], + 'protocol_handlers': [{ + 'protocol': 'web+test-replace', + 'url': 'test-format-web/%s', + }], }; const twaManifest = new TwaManifest({ 'packageId': 'id', @@ -451,13 +479,27 @@ describe('TwaManifest', () => { 'fullScopeUrl': 'https://name.github.io/', 'appVersion': '1', 'serviceAccountJsonFile': '/home/service-account.json', + 'protocolHandlers': [ + { + 'protocol': 'web+test-replace', + 'url': 'test-format-twa/%s', + }, + { + 'protocol': 'web+test-keep', + 'url': 'test-format-twa/%s', + }, + ], }); // The versions shouldn't change because the update happens in `cli`. const expectedTwaManifest = twaManifest; // A URL to insert as the webManifestUrl. const url = new URL('https://name.github.io/manifest.json'); - expect(await TwaManifest.merge(['short_name', 'display'], url, webManifest, twaManifest)) - .toEqual(expectedTwaManifest); + expect(await TwaManifest.merge( + ['short_name', 'display', 'protocol_handlers'], + url, + webManifest, + twaManifest, + )).toEqual(expectedTwaManifest); }); }); }); diff --git a/packages/core/template_project/app/src/main/AndroidManifest.xml b/packages/core/template_project/app/src/main/AndroidManifest.xml index 1943a13f..f9231c14 100644 --- a/packages/core/template_project/app/src/main/AndroidManifest.xml +++ b/packages/core/template_project/app/src/main/AndroidManifest.xml @@ -207,6 +207,10 @@ android:host="<%= additionalOrigin %>"/> <% } %> + + <% for(const entry of androidManifest.launcherActivityEntries) { %> + <%= entry %> + <% } %>