From 657f5295e659700526c053ba17d24dd78f1afb1d Mon Sep 17 00:00:00 2001 From: Joseph Pender Date: Tue, 15 Sep 2026 15:08:26 -0500 Subject: [PATCH 1/2] working on migrator for android edge to edge config --- cli/src/tasks/migrate-edge-to-edge.ts | 208 ++++++++++++++++++++++++++ cli/src/tasks/migrate.ts | 12 ++ 2 files changed, 220 insertions(+) create mode 100644 cli/src/tasks/migrate-edge-to-edge.ts diff --git a/cli/src/tasks/migrate-edge-to-edge.ts b/cli/src/tasks/migrate-edge-to-edge.ts new file mode 100644 index 000000000..ee6e9d2d7 --- /dev/null +++ b/cli/src/tasks/migrate-edge-to-edge.ts @@ -0,0 +1,208 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs-extra'; +import { join } from 'path'; + +import type { Config } from '../definitions'; +import { logger } from '../log'; +import { readdirp } from '../util/fs'; + +interface Language { + imports: string[]; + enableCall: string; + onCreate: string; +} + +const JAVA: Language = { + imports: ['import android.os.Bundle;', 'import androidx.activity.EdgeToEdge;'], + enableCall: 'EdgeToEdge.enable(this);', + onCreate: ` + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + } +`, +}; + +const KOTLIN: Language = { + imports: ['import android.os.Bundle', 'import androidx.activity.enableEdgeToEdge'], + enableCall: 'enableEdgeToEdge()', + onCreate: ` + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + } +`, +}; + +export async function migrateToEdgeToEdge(config: Config): Promise { + if (config.app.extConfig.plugins?.SystemBars?.insetsHandling === 'disable') { + logger.info(`Edge-to-edge migration: SystemBars insetsHandling is 'disable', skipping.`); + return; + } + + const activities = await findBridgeActivities(config); + + if (activities.length === 0) { + logger.warn( + `Edge-to-edge migration: no Activity extending BridgeActivity found. ${manualInstructions(JAVA.enableCall)}`, + ); + return; + } + + if (activities.length > 1) { + logger.warn( + `Edge-to-edge migration: found more than one Activity extending BridgeActivity (${activities.join(', ')}), ` + + `skipping. ${manualInstructions(JAVA.enableCall)}`, + ); + return; + } + + const path = activities[0]; + const language = path.endsWith('.kt') ? KOTLIN : JAVA; + const source = readFileSync(path, 'utf-8'); + + if (hasEdgeToEdgeEnabled(source)) { + logger.info('Edge-to-edge migration: Activity already enables edge-to-edge, skipping.'); + return; + } + + const patched = patchActivitySource(source, language); + + if (!patched) { + logger.warn(`Edge-to-edge migration: could not patch ${path}. ${manualInstructions(language.enableCall)}`); + return; + } + + writeFileSync(path, patched, 'utf-8'); +} + +function manualInstructions(enableCall: string): string { + return ( + `Add ${enableCall} to the onCreate of the Activity hosting Capacitor. ` + + `Without it the 'native' insetsHandling default of the SystemBars plugin cannot apply safe area insets.` + ); +} + +async function findBridgeActivities(config: Config): Promise { + const javaDir = join(config.android.srcMainDirAbs, 'java'); + + if (!existsSync(javaDir)) { + return []; + } + + const sources = await readdirp(javaDir, { + filter: (entry) => !entry.stats.isDirectory() && (entry.path.endsWith('.java') || entry.path.endsWith('.kt')), + }); + + return sources.filter((path) => readFileSync(path, 'utf-8').includes('BridgeActivity')); +} + +function hasEdgeToEdgeEnabled(source: string): boolean { + return source.includes('EdgeToEdge.enable(') || source.includes('enableEdgeToEdge('); +} + +function patchActivitySource(source: string, language: Language): string | undefined { + const patched = insertEnableCall(source, language) ?? insertOnCreate(source, language); + + if (!patched) { + return undefined; + } + + return addImports(patched, language.imports); +} + +function insertEnableCall(source: string, language: Language): string | undefined { + const lines = source.split('\n'); + const superCall = lines.findIndex((line) => line.includes('super.onCreate(')); + + if (superCall === -1) { + return undefined; + } + + const indentation = lines[superCall].match(/^\s*/)?.[0] ?? ''; + lines.splice(superCall + 1, 0, `${indentation}${language.enableCall}`); + + return lines.join('\n'); +} + +function insertOnCreate(source: string, language: Language): string | undefined { + const classDeclaration = source.search(/\bclass\s+\w+/); + + if (classDeclaration === -1) { + return undefined; + } + + const bodyStart = source.indexOf('{', classDeclaration); + + if (bodyStart === -1) { + return undefined; + } + + const bodyEnd = findMatchingBrace(source, bodyStart); + + if (bodyEnd === -1) { + return undefined; + } + + const body = source.slice(0, bodyEnd).replace(/\s*$/, '\n'); + + return body + language.onCreate + source.slice(bodyEnd); +} + +function findMatchingBrace(source: string, openIndex: number): number { + let depth = 0; + + for (let i = openIndex; i < source.length; i++) { + if (source[i] === '{') { + depth++; + } else if (source[i] === '}') { + depth--; + if (depth === 0) { + return i; + } + } + } + + return -1; +} + +function addImports(source: string, statements: string[]): string { + const lines = source.split('\n'); + + for (const statement of statements) { + if (!lines.includes(statement)) { + lines.splice(importInsertIndex(lines, statement), 0, statement); + } + } + + return lines.join('\n'); +} + +function importInsertIndex(lines: string[], statement: string): number { + const sortsAfter = lines.findIndex((line) => line.startsWith('import ') && line > statement); + + if (sortsAfter !== -1) { + return sortsAfter; + } + + let lastImport = -1; + lines.forEach((line, index) => { + if (line.startsWith('import ')) { + lastImport = index; + } + }); + + if (lastImport !== -1) { + return lastImport + 1; + } + + return lines.findIndex((line) => line.startsWith('package ')) + 1; +} + +export const __testables = { + JAVA, + KOTLIN, + addImports, + hasEdgeToEdgeEnabled, + patchActivitySource, +}; diff --git a/cli/src/tasks/migrate.ts b/cli/src/tasks/migrate.ts index dcc8898fd..b813c3fcd 100644 --- a/cli/src/tasks/migrate.ts +++ b/cli/src/tasks/migrate.ts @@ -13,6 +13,7 @@ import { deleteFolderRecursive } from '../util/fs'; import { runCommand } from '../util/subprocess'; import { extractTemplate } from '../util/template'; +import { migrateToEdgeToEdge } from './migrate-edge-to-edge'; import { migrateToUIScene } from './migrate-uiscene'; // eslint-disable-next-line prefer-const @@ -217,6 +218,10 @@ export async function migrateCommand(config: Config, noprompt: boolean, packagem return updateAndroidManifest(join(config.android.srcMainDirAbs, 'AndroidManifest.xml')); }); + await runTask(`Migrating the main Activity to enable edge-to-edge.`, () => { + return migrateToEdgeToEdge(config); + }); + const gradleWrapperPath = join(config.android.platformDirAbs, 'gradle', 'wrapper', 'gradle-wrapper.properties'); const gradleWrapperVersion = getGradleWrapperVersion(gradleWrapperPath); @@ -468,6 +473,13 @@ async function writeBreakingChanges() { )}.`, ); } + if (allDependencies['@capacitor/android']) { + logger.info( + `IMPORTANT: The SystemBars 'insetsHandling' option now defaults to 'native' instead of 'css'. ` + + `If your app reads the '--safe-area-inset-*' CSS variables that Capacitor injected, ` + + `set 'plugins.SystemBars.insetsHandling' to 'css' in your Capacitor configuration file.`, + ); + } if (allDependencies['@capacitor/ios']) { logger.info( 'IMPORTANT: Capacitor 8.5 adopts UIScene on iOS. ' + From 1d1abe236a80a72a4ee732ef203d94ba2c34bc0c Mon Sep 17 00:00:00 2001 From: Joseph Pender Date: Tue, 15 Sep 2026 15:10:23 -0500 Subject: [PATCH 2/2] adding tests --- cli/test/migrate-edge-to-edge.spec.ts | 323 ++++++++++++++++++++++++++ 1 file changed, 323 insertions(+) create mode 100644 cli/test/migrate-edge-to-edge.spec.ts diff --git a/cli/test/migrate-edge-to-edge.spec.ts b/cli/test/migrate-edge-to-edge.spec.ts new file mode 100644 index 000000000..0fdfd05cc --- /dev/null +++ b/cli/test/migrate-edge-to-edge.spec.ts @@ -0,0 +1,323 @@ +import { mkdirp, readFileSync, writeFileSync } from 'fs-extra'; +import { join } from 'path'; + +import type { Config } from '../src/definitions'; +import { logger } from '../src/log'; +import { migrateToEdgeToEdge, __testables } from '../src/tasks/migrate-edge-to-edge'; + +import { mktmp } from './util'; + +const { JAVA, KOTLIN, addImports, hasEdgeToEdgeEnabled, patchActivitySource } = __testables; + +// MainActivity.java of an app created with Capacitor 8 +const CAP8_ACTIVITY = `package com.example.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity {} +`; + +// MainActivity.java of an app created with Capacitor 9 +const CAP9_ACTIVITY = `package com.example.app; + +import android.os.Bundle; +import androidx.activity.EdgeToEdge; +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + } +} +`; + +const ACTIVITY_WITH_ONCREATE = `package com.example.app; + +import android.os.Bundle; +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + getWindow().setBackgroundDrawable(null); + } +} +`; + +const ACTIVITY_WITHOUT_ONCREATE = `package com.example.app; + +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { + + private void doNothing() {} +} +`; + +const KOTLIN_ACTIVITY_WITH_ONCREATE = `package com.example.app + +import android.os.Bundle +import com.getcapacitor.BridgeActivity + +class MainActivity : BridgeActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } +} +`; + +const KOTLIN_ACTIVITY_WITHOUT_BODY = `package com.example.app + +import com.getcapacitor.BridgeActivity + +class MainActivity : BridgeActivity() +`; + +describe('patchActivitySource', () => { + it('turns a Capacitor 8 activity into the Capacitor 9 one', () => { + // Arrange + const source = CAP8_ACTIVITY; + + // Act + const result = patchActivitySource(source, JAVA); + + // Assert + expect(result).toBe(CAP9_ACTIVITY); + }); + + it('adds the enable call to an existing onCreate', () => { + // Arrange + const source = ACTIVITY_WITH_ONCREATE; + + // Act + const result = patchActivitySource(source, JAVA); + + // Assert + expect(result).toContain( + ' super.onCreate(savedInstanceState);\n' + + ' EdgeToEdge.enable(this);\n' + + ' getWindow().setBackgroundDrawable(null);\n', + ); + }); + + it('adds only the missing imports, in sorted position', () => { + // Arrange + const source = ACTIVITY_WITH_ONCREATE; + + // Act + const result = patchActivitySource(source, JAVA); + + // Assert + expect(result).toContain( + 'import android.os.Bundle;\nimport androidx.activity.EdgeToEdge;\nimport com.getcapacitor.BridgeActivity;\n', + ); + }); + + it('appends the onCreate override to an activity that has other members', () => { + // Arrange + const source = ACTIVITY_WITHOUT_ONCREATE; + + // Act + const result = patchActivitySource(source, JAVA); + + // Assert + expect(result).toBe(`package com.example.app; + +import android.os.Bundle; +import androidx.activity.EdgeToEdge; +import com.getcapacitor.BridgeActivity; + +public class MainActivity extends BridgeActivity { + + private void doNothing() {} + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + EdgeToEdge.enable(this); + } +} +`); + }); + + it('adds the enable call to an existing Kotlin onCreate', () => { + // Arrange + const source = KOTLIN_ACTIVITY_WITH_ONCREATE; + + // Act + const result = patchActivitySource(source, KOTLIN); + + // Assert + expect(result).toBe(`package com.example.app + +import android.os.Bundle +import androidx.activity.enableEdgeToEdge +import com.getcapacitor.BridgeActivity + +class MainActivity : BridgeActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + } +} +`); + }); + + it('gives up on a Kotlin activity without a class body', () => { + // Arrange + const source = KOTLIN_ACTIVITY_WITHOUT_BODY; + + // Act + const result = patchActivitySource(source, KOTLIN); + + // Assert + expect(result).toBeUndefined(); + }); +}); + +describe('hasEdgeToEdgeEnabled', () => { + it('finds the Java call', () => { + expect(hasEdgeToEdgeEnabled(CAP9_ACTIVITY)).toBe(true); + }); + + it('finds the Kotlin call', () => { + expect(hasEdgeToEdgeEnabled(' enableEdgeToEdge()\n')).toBe(true); + }); + + it('does not count the Kotlin import as the call', () => { + expect(hasEdgeToEdgeEnabled('import androidx.activity.enableEdgeToEdge\n')).toBe(false); + }); + + it('returns false for an untouched activity', () => { + expect(hasEdgeToEdgeEnabled(CAP8_ACTIVITY)).toBe(false); + }); +}); + +describe('addImports', () => { + it('leaves an import that is already there alone', () => { + // Arrange + const source = CAP9_ACTIVITY; + + // Act + const result = addImports(source, JAVA.imports); + + // Assert + expect(result).toBe(CAP9_ACTIVITY); + }); + + it('adds imports to a file that has none', () => { + // Arrange + const source = 'package com.example.app;\n\npublic class MainActivity {}\n'; + + // Act + const result = addImports(source, ['import android.os.Bundle;']); + + // Assert + expect(result).toBe('package com.example.app;\nimport android.os.Bundle;\n\npublic class MainActivity {}\n'); + }); +}); + +describe('edge-to-edge migration against a project on disk', () => { + let tmpDir: any; + let javaDir: string; + let activityPath: string; + + beforeEach(async () => { + jest.spyOn(logger, 'info').mockImplementation(); + jest.spyOn(logger, 'warn').mockImplementation(); + + tmpDir = await mktmp(); + javaDir = join(tmpDir.path, 'android', 'app', 'src', 'main', 'java', 'com', 'example', 'app'); + activityPath = join(javaDir, 'MainActivity.java'); + await mkdirp(javaDir); + }); + + afterEach(() => { + jest.restoreAllMocks(); + tmpDir.cleanupCallback(); + }); + + function makeConfig(insetsHandling?: string): Config { + return { + android: { srcMainDirAbs: join(tmpDir.path, 'android', 'app', 'src', 'main') }, + app: { extConfig: { plugins: { SystemBars: { insetsHandling } } } }, + } as unknown as Config; + } + + it('patches the activity extending BridgeActivity', async () => { + // Arrange + writeFileSync(activityPath, CAP8_ACTIVITY, 'utf-8'); + + // Act + await migrateToEdgeToEdge(makeConfig()); + + // Assert + expect(readFileSync(activityPath, 'utf-8')).toBe(CAP9_ACTIVITY); + }); + + it('leaves the activity alone when insetsHandling is disabled', async () => { + // Arrange + writeFileSync(activityPath, CAP8_ACTIVITY, 'utf-8'); + + // Act + await migrateToEdgeToEdge(makeConfig('disable')); + + // Assert + expect(readFileSync(activityPath, 'utf-8')).toBe(CAP8_ACTIVITY); + }); + + it('does not enable edge-to-edge twice', async () => { + // Arrange + writeFileSync(activityPath, CAP8_ACTIVITY, 'utf-8'); + + // Act + await migrateToEdgeToEdge(makeConfig()); + await migrateToEdgeToEdge(makeConfig()); + + // Assert + expect(readFileSync(activityPath, 'utf-8')).toBe(CAP9_ACTIVITY); + }); + + it('warns when there is no activity extending BridgeActivity', async () => { + // Arrange + writeFileSync(join(javaDir, 'MyPlugin.java'), 'package com.example.app;\n', 'utf-8'); + + // Act + await migrateToEdgeToEdge(makeConfig()); + + // Assert + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('no Activity extending BridgeActivity')); + }); + + it('warns and skips when more than one activity extends BridgeActivity', async () => { + // Arrange + const otherPath = join(javaDir, 'OtherActivity.java'); + writeFileSync(activityPath, CAP8_ACTIVITY, 'utf-8'); + writeFileSync(otherPath, CAP8_ACTIVITY.replace('MainActivity', 'OtherActivity'), 'utf-8'); + + // Act + await migrateToEdgeToEdge(makeConfig()); + + // Assert + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('more than one Activity')); + expect(readFileSync(activityPath, 'utf-8')).toBe(CAP8_ACTIVITY); + }); + + it('warns when the android platform is not there', async () => { + // Act + await migrateToEdgeToEdge({ + android: { srcMainDirAbs: join(tmpDir.path, 'missing') }, + app: { extConfig: {} }, + } as unknown as Config); + + // Assert + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('no Activity extending BridgeActivity')); + }); +});