diff --git a/.changeset/rn-android-cold-start-push-consent.md b/.changeset/rn-android-cold-start-push-consent.md new file mode 100644 index 0000000000..609aaada44 --- /dev/null +++ b/.changeset/rn-android-cold-start-push-consent.md @@ -0,0 +1,5 @@ +--- +'@posthog/react-native-plugin': patch +--- + +Android: a notification tap that launched the app is no longer captured as `$push_notification_opened` while the JS client is opted out, even if an earlier launch had opted the native SDK in. diff --git a/.changeset/rn-android-main-activity-new-intent.md b/.changeset/rn-android-main-activity-new-intent.md new file mode 100644 index 0000000000..1f08a59671 --- /dev/null +++ b/.changeset/rn-android-main-activity-new-intent.md @@ -0,0 +1,5 @@ +--- +'posthog-react-native': minor +--- + +Fix `$push_notification_opened` not being captured on Android when the app's process was killed but its task stayed in recents (opt out with `{ patchMainActivityNewIntent: false }`). diff --git a/packages/react-native-plugin/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt b/packages/react-native-plugin/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt index b5c45cb03d..5122e97e8f 100644 --- a/packages/react-native-plugin/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt +++ b/packages/react-native-plugin/android/src/main/java/com/posthogreactnativeplugin/PosthogReactNativePluginModule.kt @@ -220,7 +220,7 @@ class PosthogReactNativePluginModule( setIdentify(config.cachePreferences, distinctId, anonymousId) - captureColdStartPushOpenIfNeeded(config) + captureColdStartPushOpenIfNeeded(config, jsOptedOut = theOptOut) } catch (e: Throwable) { logError(method, e) } finally { @@ -487,7 +487,15 @@ class PosthogReactNativePluginModule( // cold-start tray tap it exists for is the one creation it can never observe here. Read // the launch intent directly, then strip the marker so the integration (or a re-run) // can't capture the same tap again from this intent object. - private fun captureColdStartPushOpenIfNeeded(config: PostHogAndroidConfig) { + // + // jsOptedOut is the consent JS passed into this setup(). The native SDK lets the opt-out it + // persisted itself win over that value, so after an earlier launch opted in, config.optOut no + // longer says what JS said and native would capture. A tap JS considers denied is consumed + // here without being captured, so a later opt-in cannot resurrect it either. + private fun captureColdStartPushOpenIfNeeded( + config: PostHogAndroidConfig, + jsOptedOut: Boolean, + ) { if (!config.capturePushNotificationOpened) { return } @@ -499,6 +507,10 @@ class PosthogReactNativePluginModule( } try { intent.getStringExtra(GOOGLE_MESSAGE_ID) ?: return + if (jsOptedOut) { + intent.removeExtra(GOOGLE_MESSAGE_ID) + return + } // Unmarshalling extras throws BadParcelableException on a Parcelable class this // classloader lacks; read before stripping the marker so a failed read leaves the // intent as the native integration expects it. diff --git a/packages/react-native/package.json b/packages/react-native/package.json index a870c5d057..5f360f2ddf 100644 --- a/packages/react-native/package.json +++ b/packages/react-native/package.json @@ -97,7 +97,7 @@ "expo-file-system": ">= 13.0.0", "expo-localization": ">= 11.0.0", "expo-updates": ">= 0.25.0", - "@posthog/react-native-plugin": ">= 2.4.3", + "@posthog/react-native-plugin": ">= 2.9.3", "posthog-react-native-session-replay": ">= 1.6.0", "react-native-device-info": ">= 10.0.0", "react-native-localize": ">= 3.0.0", diff --git a/packages/react-native/src/tooling/expoconfig.ts b/packages/react-native/src/tooling/expoconfig.ts index eab0e1fbf7..4b28f1adfc 100644 --- a/packages/react-native/src/tooling/expoconfig.ts +++ b/packages/react-native/src/tooling/expoconfig.ts @@ -3,11 +3,13 @@ // Licensed under the MIT License: https://github.com/getsentry/sentry-react-native/blob/main/LICENSE.md const fs = require('fs') +const path = require('path') const { AndroidConfig, withAppBuildGradle, withBaseMod, + withDangerousMod, withGradleProperties, withXcodeProject, } = require('@expo/config-plugins') @@ -233,6 +235,359 @@ const withAndroidNativeSymbolsPlugin = (config: any) => { }) } +const POSTHOG_NEW_INTENT_MARKER = 'posthog-new-intent' +const POSTHOG_NEW_INTENT_BEGIN = `// @generated begin ${POSTHOG_NEW_INTENT_MARKER} - posthog-react-native (DO NOT MODIFY)` +const POSTHOG_NEW_INTENT_END = `// @generated end ${POSTHOG_NEW_INTENT_MARKER}` + +// `android.content.Intent` is spelled out to keep the block self-contained: adding an import is a +// second, riskier edit, and the templates we patch do not already import Intent. +const NEW_INTENT_DOC = ` /** + * Records the intent that reopened the app so getIntent() stays correct. + * + * Works around a React Native defect that drops notification taps and deep links arriving while + * the React context is still starting. Managed by the posthog-react-native Expo config plugin; + * remove it with { patchMainActivityNewIntent: false } in app.json. + * https://posthog.com/docs/workflows/push-notifications/react-native + */` + +const NEW_INTENT_KOTLIN_BODY = ` override fun onNewIntent(intent: android.content.Intent) { + setIntent(intent) + super.onNewIntent(intent) + }` + +const NEW_INTENT_JAVA_BODY = ` @Override + public void onNewIntent(android.content.Intent intent) { + setIntent(intent); + super.onNewIntent(intent); + }` + +function newIntentOverrideBlock(language: string): string { + const body = language === 'java' ? NEW_INTENT_JAVA_BODY : NEW_INTENT_KOTLIN_BODY + return `\n ${POSTHOG_NEW_INTENT_BEGIN}\n${NEW_INTENT_DOC}\n${body}\n ${POSTHOG_NEW_INTENT_END}\n` +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Lazy body match so two blocks (only reachable from a hand-edited file) are removed separately +// rather than swallowing everything between them. The `\r?` on both ends keeps the block removable +// after an editor or a Windows checkout has normalized the file to CRLF. +const POSTHOG_NEW_INTENT_BLOCK_PATTERN = new RegExp( + `\\r?\\n?[ \\t]*${escapeRegExp(POSTHOG_NEW_INTENT_BEGIN)}[\\s\\S]*?${escapeRegExp( + POSTHOG_NEW_INTENT_END + )}[ \\t]*\\r?\\n`, + 'g' +) + +// Index just past the string or character literal opening at `start`, or -1 when it never closes. +// Covers `"..."` and `'...'` with backslash escapes (which end at a newline in both languages), +// `"""..."""` raw strings and text blocks, and Kotlin `${...}` templates, whose contents are code +// that may nest further literals. +function literalEnd(s: string, start: number, language: string): number { + const quote = s[start] + const raw = quote === '"' && s.startsWith('"""', start) + let i = start + (raw ? 3 : 1) + while (i < s.length) { + const c = s[i] + if (raw) { + if (s.startsWith('"""', i)) { + // Kotlin closes on the last three of a longer run of quotes. + while (s[i] === '"') { + i++ + } + return i + } + } else if (c === '\n') { + return -1 + } else if (c === quote) { + return i + 1 + } + if (c === '\\' && (!raw || language === 'java')) { + i += 2 + continue + } + if (language === 'kt' && quote === '"' && c === '$' && s[i + 1] === '{') { + const close = matchingBraceIndexInSource(s, i + 1, language) + if (close === -1) { + return -1 + } + i = close + 1 + continue + } + i++ + } + return -1 +} + +// Index of the `}` matching the `{` at openBraceIndex in Kotlin or Java source, or -1 if +// unbalanced. Braces inside literals and comments are not structural: a `"}"` field before an +// existing onNewIntent must not end the class early, which would hide that override from the +// scoped check below and make us insert a duplicate the file no longer compiles with. +function matchingBraceIndexInSource(s: string, openBraceIndex: number, language: string): number { + let depth = 0 + let i = openBraceIndex + while (i < s.length) { + const c = s[i] + if (c === '/' && s[i + 1] === '/') { + const end = s.indexOf('\n', i) + i = end === -1 ? s.length : end + continue + } + if (c === '/' && s[i + 1] === '*') { + // Kotlin nests block comments, Java does not: taking the first `*/` in Kotlin would end the + // comment early and let a commented-out `}` close the class, hiding a real override below it. + let depthOfComment = 1 + i += 2 + while (i < s.length && depthOfComment > 0) { + if (language === 'kt' && s[i] === '/' && s[i + 1] === '*') { + depthOfComment++ + i += 2 + continue + } + if (s[i] === '*' && s[i + 1] === '/') { + depthOfComment-- + i += 2 + continue + } + i++ + } + if (depthOfComment > 0) { + return -1 + } + continue + } + if (c === '"' || c === "'") { + const end = literalEnd(s, i, language) + if (end === -1) { + return -1 + } + i = end + continue + } + if (c === '{') { + depth++ + } else if (c === '}') { + depth-- + if (depth === 0) { + return i + } + } + i++ + } + return -1 +} + +// A copy of `source` with the inside of every comment and string/char literal replaced by spaces, +// keeping length and line breaks so indexes still line up with the original. One pass, so the class +// declaration, the brace scan and the existing-override check all agree on what is code: a +// commented-out `class MainActivity`, a `"}"` field, or an `onNewIntent` inside a comment are all +// invisible to every one of them. Kotlin nests block comments and Java does not. +function maskCommentsAndLiterals(source: string, language: string): string { + const out = source.split('') + const blank = (from: number, to: number) => { + for (let j = from; j < to && j < out.length; j++) { + if (out[j] !== '\n') { + out[j] = ' ' + } + } + } + + let i = 0 + while (i < source.length) { + const c = source[i] + if (c === '/' && source[i + 1] === '/') { + const end = source.indexOf('\n', i) + const stop = end === -1 ? source.length : end + blank(i, stop) + i = stop + continue + } + if (c === '/' && source[i + 1] === '*') { + let depth = 1 + let j = i + 2 + while (j < source.length && depth > 0) { + if (language === 'kt' && source[j] === '/' && source[j + 1] === '*') { + depth++ + j += 2 + continue + } + if (source[j] === '*' && source[j + 1] === '/') { + depth-- + j += 2 + continue + } + j++ + } + blank(i, j) + i = j + continue + } + if (c === '"' || c === "'") { + const end = literalEnd(source, i, language) + if (end === -1) { + // Unterminated literal: blank the rest so nothing after it reads as code. + blank(i, source.length) + return out.join('') + } + blank(i, end) + i = end + continue + } + i++ + } + return out.join('') +} + +// The span of MainActivity's body, or undefined when the file does not look like the templates we +// patch: a supertype list is all we expect between the class name and the opening brace. +function mainActivityBody(contents: string, language: string): { open: number; close: number } | undefined { + const code = maskCommentsAndLiterals(contents, language) + const declaration = /\bclass\s+MainActivity\b/.exec(code) + if (!declaration) { + return undefined + } + const searchFrom = declaration.index + declaration[0].length + const open = code.indexOf('{', searchFrom) + if (open === -1 || !/^[^;{}]*$/.test(code.slice(searchFrom, open))) { + return undefined + } + // Unbalanced braces mean we cannot tell where the body ends, so the file is not ours to edit. + let depth = 0 + let close = -1 + for (let i = open; i < code.length; i++) { + if (code[i] === '{') { + depth++ + } else if (code[i] === '}') { + depth-- + if (depth === 0) { + close = i + break + } + } + } + return close === -1 ? undefined : { open, close } +} + +/** + * Adds (or, when disabled, removes) the managed `onNewIntent` override in MainActivity. + * + * Idempotent: the block is delimited by generated markers and rewritten in place, so repeated + * prebuilds never stack copies. An app that already overrides `onNewIntent` keeps its own — a + * second override would not compile, and the one-line `setIntent(intent)` belongs at the top of + * theirs instead. + */ +export function updateMainActivityNewIntentOverride(contents: string, language: string, enabled: boolean): string { + const withoutManagedBlock = contents.replace(POSTHOG_NEW_INTENT_BLOCK_PATTERN, '') + if (!enabled) { + return withoutManagedBlock + } + + const body = mainActivityBody(withoutManagedBlock, language) + if (!body) { + console.warn( + '[posthog-react-native] Could not find the MainActivity class body; skipping the onNewIntent ' + + 'override. Notification taps delivered while the React context is starting will be lost.' + ) + return withoutManagedBlock + } + + // Scoped to MainActivity's own body, and matching a declaration rather than the bare token: an + // onNewIntent on a helper class in the same file, or named in a comment or a string, must not + // turn the fix off — but every real override of it in either language matches. + const codeOnly = maskCommentsAndLiterals(withoutManagedBlock, language) + if (/\b(fun|void)\s+onNewIntent\s*\(/.test(codeOnly.slice(body.open, body.close))) { + console.warn( + '[posthog-react-native] MainActivity already overrides onNewIntent; leaving it alone. ' + + 'Add `setIntent(intent)` as its first statement so a notification tap that arrives before ' + + 'the React context is ready is not lost, or set `{ patchMainActivityNewIntent: false }` ' + + 'on the plugin to silence this.' + ) + return withoutManagedBlock + } + + return ( + withoutManagedBlock.slice(0, body.open + 1) + + newIntentOverrideBlock(language) + + withoutManagedBlock.slice(body.open + 1) + ) +} + +// Expo's own `mainActivity` mod resolves the file with a glob over android/app/src/main/java only, +// and asserts, so registering it turns sources under src/main/kotlin — or no MainActivity at all — +// into a hard prebuild failure whose message never mentions PostHog. Look the file up ourselves +// instead. Dangerous mods run before the standard android chain, so another plugin's +// withMainActivity still reads (and re-writes) our edit. +const MAIN_ACTIVITY_SOURCE_ROOTS = ['android/app/src/main/java', 'android/app/src/main/kotlin'] + +function findMainActivityPath(projectRoot: string): string | undefined { + const walk = (dir: string): string | undefined => { + if (!fs.existsSync(dir)) { + return undefined + } + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const candidate = path.join(dir, entry.name) + if (!entry.isDirectory()) { + if (/^MainActivity\.(kt|java)$/.test(entry.name)) { + return candidate + } + continue + } + const hit = walk(candidate) + if (hit) { + return hit + } + } + return undefined + } + + for (const sourceRoot of MAIN_ACTIVITY_SOURCE_ROOTS) { + const hit = walk(path.join(projectRoot, sourceRoot)) + if (hit) { + return hit + } + } + return undefined +} + +const withMainActivityNewIntent = (config: any, enabled: boolean) => { + return withDangerousMod(config, [ + 'android', + async (config: any) => { + const mainActivityPath = findMainActivityPath(config.modRequest.projectRoot) + if (!mainActivityPath) { + console.warn( + '[posthog-react-native] Could not find MainActivity under android/app/src/main/{java,kotlin}; ' + + 'skipping the onNewIntent override. Notification taps delivered while the React context is ' + + 'starting will be lost.' + ) + return config + } + + const contents = await fs.promises.readFile(mainActivityPath, 'utf8') + const updated = updateMainActivityNewIntentOverride( + contents, + mainActivityPath.endsWith('.java') ? 'java' : 'kt', + enabled + ) + if (updated !== contents) { + await fs.promises.writeFile(mainActivityPath, updated) + } + // Only when the block is new, so a re-run of an already-patched project stays quiet. + if (!contents.includes(POSTHOG_NEW_INTENT_BEGIN) && updated.includes(POSTHOG_NEW_INTENT_BEGIN)) { + console.warn( + `[posthog-react-native] Added an onNewIntent override to ${path.relative( + config.modRequest.projectRoot, + mainActivityPath + )} so a notification tap that arrives before the React context is ready is not lost. ` + + 'Set `{ patchMainActivityNewIntent: false }` on the plugin in app.json to opt out.' + ) + } + return config + }, + ]) +} + type BuildPhase = { shellScript: string } export function modifyExistingXcodeBuildScript( @@ -719,6 +1074,23 @@ type PostHogPluginProps = { * posthog.gradle: update them and this line together. */ releaseMode?: PostHogReleaseMode + + /** + * Whether to give Android's `MainActivity` an `onNewIntent` override that calls + * `setIntent(intent)` before delegating to React Native. + * + * Works around a React Native defect. When Android reopens an app whose process it had killed + * while the task stayed in recents, the tap arrives before the React context is ready and is then + * invisible to the whole process — PostHog captures no `$push_notification_opened`, Firebase + * Messaging's `getInitialNotification()` returns null, and deep links are lost. Recording the + * intent first makes `getIntent()` correct for every library in the app. + * + * Default: true. The plugin leaves a `MainActivity` that already overrides `onNewIntent` + * untouched and warns instead — add `setIntent(intent)` as the first statement of your own + * override. Set to false to skip the injection entirely (and remove one a previous prebuild + * wrote); bare React Native apps that do not run `expo prebuild` need the same override by hand. + */ + patchMainActivityNewIntent?: boolean } // Normalizes the uploadNativeSymbols prop (boolean | { includeSource }) into a @@ -807,6 +1179,7 @@ const withPostHogPlugin = (config: any, rawProps: PostHogPluginProps = {}) => { config = withAndroidPlugin(config, props.skipOnConflict === true) // Runs unconditionally so removing the prop also removes the managed entry. config = withPostHogGradleProperties(config, props.dotenvFile, props.releaseMode) + config = withMainActivityNewIntent(config, props.patchMainActivityNewIntent !== false) return withIosPlugin(config, props) } @@ -835,3 +1208,4 @@ module.exports.updateDotenvFileGradleProperties = updateDotenvFileGradleProperti module.exports.POSTHOG_RELEASE_MODES = POSTHOG_RELEASE_MODES module.exports.resolveReleaseModeProp = resolveReleaseModeProp module.exports.updateHermesReleaseModeGradleProperties = updateHermesReleaseModeGradleProperties +module.exports.updateMainActivityNewIntentOverride = updateMainActivityNewIntentOverride diff --git a/packages/react-native/test/expoconfig-android-mods.spec.ts b/packages/react-native/test/expoconfig-android-mods.spec.ts index 28f0b9305c..8a3401e407 100644 --- a/packages/react-native/test/expoconfig-android-mods.spec.ts +++ b/packages/react-native/test/expoconfig-android-mods.spec.ts @@ -11,6 +11,26 @@ const projectGradle = 'buildscript {\n dependencies {\n classpath("com.android.tools.build:gradle")\n }\n}\n' const appGradle = 'apply plugin: "com.android.application"\n\nandroid {\n namespace "com.example"\n}\n' const applyLine = 'apply plugin: "com.posthog.android"' +const mainActivityDir = 'android/app/src/main/java/com/example' +const mainActivity = `package com.example + +import com.facebook.react.ReactActivity + +class MainActivity : ReactActivity() { + override fun getMainComponentName(): String = "main" +} +` +const javaMainActivity = `package com.example; + +import com.facebook.react.ReactActivity; + +public class MainActivity extends ReactActivity { + @Override + protected String getMainComponentName() { + return "main"; + } +} +` // Use Expo's real providers/compiler so both mod-kind ordering and persisted files are exercised. describe.each([false, true])('Android native symbols with earlier app mod: %s', (earlierAppMod) => { @@ -20,10 +40,11 @@ describe.each([false, true])('Android native symbols with earlier app mod: %s', vi.useRealTimers() vi.spyOn(console, 'warn').mockImplementation(() => {}) projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'posthog-expo-android-')) - fs.mkdirSync(path.join(projectRoot, 'android/app'), { recursive: true }) + fs.mkdirSync(path.join(projectRoot, mainActivityDir), { recursive: true }) fs.writeFileSync(path.join(projectRoot, 'android/build.gradle'), projectGradle) fs.writeFileSync(path.join(projectRoot, 'android/app/build.gradle'), appGradle) fs.writeFileSync(path.join(projectRoot, 'android/gradle.properties'), '') + fs.writeFileSync(path.join(projectRoot, mainActivityDir, 'MainActivity.kt'), mainActivity) }) afterEach(() => { @@ -32,14 +53,14 @@ describe.each([false, true])('Android native symbols with earlier app mod: %s', vi.useFakeTimers() }) - async function prebuild(uploadNativeSymbols = true) { + async function prebuild(uploadNativeSymbols = true, props: Record = {}) { let config: any = { name: 'Test', slug: 'test' } const appMod = vi.fn((config) => config) const projectMod = vi.fn((config) => config) if (earlierAppMod) { config = withAppBuildGradle(config, appMod) } - config = postHogExpoPlugin(config, { uploadNativeSymbols }) + config = postHogExpoPlugin(config, { uploadNativeSymbols, ...props }) if (!earlierAppMod) { config = withAppBuildGradle(config, appMod) } @@ -53,6 +74,14 @@ describe.each([false, true])('Android native symbols with earlier app mod: %s', return fs.readFileSync(path.join(projectRoot, 'android', file), 'utf8') } + function readMainActivity() { + return fs.readFileSync(path.join(projectRoot, mainActivityDir, 'MainActivity.kt'), 'utf8') + } + + function warnings() { + return vi.mocked(console.warn).mock.calls.map((call) => call[0]) + } + it('writes both native-symbol Gradle edits and remains idempotent on another prebuild', async () => { await prebuild() const project = readGradle('build.gradle') @@ -60,7 +89,7 @@ describe.each([false, true])('Android native symbols with earlier app mod: %s', expect(project).toContain('classpath("com.posthog:posthog-android-gradle-plugin:') expect(app.split(applyLine)).toHaveLength(2) expect(app).toContain('posthog.gradle') - expect(console.warn).not.toHaveBeenCalled() + expect(warnings()).toEqual([expect.stringContaining('Added an onNewIntent override')]) await prebuild() expect(readGradle('build.gradle')).toBe(project) @@ -101,4 +130,57 @@ describe.each([false, true])('Android native symbols with earlier app mod: %s', expect(readGradle('app/build.gradle')).not.toContain(applyLine) expect(readGradle('app/build.gradle')).toContain('posthog.gradle') }) + + it('writes the MainActivity onNewIntent override and remains idempotent on another prebuild', async () => { + await prebuild() + const patched = readMainActivity() + expect(patched).toContain('override fun onNewIntent(intent: android.content.Intent) {') + expect(patched).toContain('setIntent(intent)') + expect(warnings()).toEqual([expect.stringContaining('Added an onNewIntent override')]) + + vi.mocked(console.warn).mockClear() + await prebuild() + expect(readMainActivity()).toBe(patched) + expect(console.warn).not.toHaveBeenCalled() + }) + + it('removes the override again when opted out', async () => { + await prebuild() + await prebuild(true, { patchMainActivityNewIntent: false }) + expect(readMainActivity()).toBe(mainActivity) + }) + + it('writes the override into a MainActivity under src/main/kotlin', async () => { + fs.rmSync(path.join(projectRoot, 'android/app/src/main/java'), { recursive: true }) + const kotlinDir = path.join(projectRoot, 'android/app/src/main/kotlin/com/example') + fs.mkdirSync(kotlinDir, { recursive: true }) + fs.writeFileSync(path.join(kotlinDir, 'MainActivity.kt'), mainActivity) + + await prebuild() + + expect(fs.readFileSync(path.join(kotlinDir, 'MainActivity.kt'), 'utf8')).toContain( + 'override fun onNewIntent(intent: android.content.Intent) {' + ) + }) + + it('writes the Java form of the override into a MainActivity.java', async () => { + fs.rmSync(path.join(projectRoot, mainActivityDir, 'MainActivity.kt')) + const javaPath = path.join(projectRoot, mainActivityDir, 'MainActivity.java') + fs.writeFileSync(javaPath, javaMainActivity) + + await prebuild() + + expect(fs.readFileSync(javaPath, 'utf8')).toContain( + ' @Override\n public void onNewIntent(android.content.Intent intent) {' + ) + }) + + it('warns and writes nothing when the project has no MainActivity', async () => { + fs.rmSync(path.join(projectRoot, 'android/app/src'), { recursive: true }) + + await expect(prebuild()).resolves.toBeUndefined() + + expect(fs.existsSync(path.join(projectRoot, 'android/app/src'))).toBe(false) + expect(warnings()).toContainEqual(expect.stringContaining('Could not find MainActivity under')) + }) }) diff --git a/packages/react-native/test/expoconfig.spec.ts b/packages/react-native/test/expoconfig.spec.ts index 15eb1815e2..cdc0d44d46 100644 --- a/packages/react-native/test/expoconfig.spec.ts +++ b/packages/react-native/test/expoconfig.spec.ts @@ -23,6 +23,7 @@ import { resolveReleaseModeProp, updateHermesReleaseModeGradleProperties, updateDotenvFileGradleProperties, + updateMainActivityNewIntentOverride, } from '../src/tooling/expoconfig' const postHogExpoPlugin = (postHogExpoPluginModule as any).default @@ -914,6 +915,306 @@ describe('resolveReleaseModeProp', () => { }) }) +const kotlinMainActivity = `package com.example + +import com.facebook.react.ReactActivity + +class MainActivity : ReactActivity() { + override fun getMainComponentName(): String = "main" +} +` + +const javaMainActivity = `package com.example; + +import com.facebook.react.ReactActivity; + +public class MainActivity extends ReactActivity { + @Override + protected String getMainComponentName() { + return "main"; + } +} +` + +describe('updateMainActivityNewIntentOverride', () => { + beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('injects a Kotlin override into the class body', () => { + const result = updateMainActivityNewIntentOverride(kotlinMainActivity, 'kt', true) + + expect(result).toContain('override fun onNewIntent(intent: android.content.Intent) {') + expect(result).toContain(' setIntent(intent)\n super.onNewIntent(intent)') + expect(result.indexOf('setIntent(intent)')).toBeLessThan(result.indexOf('super.onNewIntent(intent)')) + expect(result.indexOf('class MainActivity')).toBeLessThan(result.indexOf('onNewIntent')) + expect(result).toContain('getMainComponentName') + expect(console.warn).not.toHaveBeenCalled() + }) + + it('injects a Java override into the class body', () => { + const result = updateMainActivityNewIntentOverride(javaMainActivity, 'java', true) + + expect(result).toContain(' @Override\n public void onNewIntent(android.content.Intent intent) {') + expect(result).toContain(' setIntent(intent);\n super.onNewIntent(intent);') + expect(result).toContain('getMainComponentName') + expect(console.warn).not.toHaveBeenCalled() + }) + + it.each([ + ['kt', kotlinMainActivity], + ['java', javaMainActivity], + ])('is idempotent for %s', (language, source) => { + const once = updateMainActivityNewIntentOverride(source, language, true) + const twice = updateMainActivityNewIntentOverride(once, language, true) + + expect(twice).toBe(once) + expect(once.split('onNewIntent(')).toHaveLength(3) + expect(console.warn).not.toHaveBeenCalled() + }) + + it.each([ + ['kt', kotlinMainActivity], + ['java', javaMainActivity], + ])('restores the original %s file when disabled', (language, source) => { + const patched = updateMainActivityNewIntentOverride(source, language, true) + + expect(updateMainActivityNewIntentOverride(patched, language, false)).toBe(source) + }) + + it('leaves an existing override alone and explains what to add', () => { + const source = kotlinMainActivity.replace( + ' override fun getMainComponentName', + ' override fun onNewIntent(intent: Intent) {\n super.onNewIntent(intent)\n }\n\n override fun getMainComponentName' + ) + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('already overrides onNewIntent')) + }) + + it('replaces a stale managed block rather than stacking a second one', () => { + const patched = updateMainActivityNewIntentOverride(kotlinMainActivity, 'kt', true) + const stale = patched.replace('setIntent(intent)', 'setIntent(intent) // hand-edited') + + expect(updateMainActivityNewIntentOverride(stale, 'kt', true)).toBe(patched) + }) + + it('skips a file with no recognizable MainActivity class body', () => { + const source = 'package com.example\n\nobject NotAnActivity\n' + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find the MainActivity class body')) + }) + + it('skips a file whose braces do not balance', () => { + const source = 'class MainActivity : ReactActivity() {\n fun broken() {\n' + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find the MainActivity class body')) + }) + + it('patches a file that only mentions onNewIntent in a comment', () => { + const source = kotlinMainActivity.replace( + ' override fun getMainComponentName', + ' // TODO: forward onNewIntent to the router\n override fun getMainComponentName' + ) + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toContain( + 'override fun onNewIntent(intent: android.content.Intent) {' + ) + expect(console.warn).not.toHaveBeenCalled() + }) + + it('still manages the block after the file is normalized to CRLF', () => { + const source = kotlinMainActivity.replace(/\n/g, '\r\n') + const patched = updateMainActivityNewIntentOverride(source, 'kt', true).replace(/\r?\n/g, '\r\n') + + expect(patched).toContain('override fun onNewIntent(intent: android.content.Intent) {') + expect(updateMainActivityNewIntentOverride(patched, 'kt', false)).toBe(source) + expect(updateMainActivityNewIntentOverride(patched, 'kt', true).split('onNewIntent(')).toHaveLength(3) + expect(console.warn).not.toHaveBeenCalled() + }) + + it('inserts into MainActivity and not a later class in the same file', () => { + const source = `${kotlinMainActivity}\nclass Helper {\n fun noop() {}\n}\n` + const result = updateMainActivityNewIntentOverride(source, 'kt', true) + + expect(result.indexOf('onNewIntent')).toBeLessThan(result.indexOf('class Helper')) + expect(console.warn).not.toHaveBeenCalled() + }) + + it('ignores a commented-out class MainActivity above the real one', () => { + const source = [ + '// class MainActivity : ReactActivity() { }', + 'class MainActivity : ReactActivity() {', + ' override fun onNewIntent(intent: android.content.Intent) {', + ' super.onNewIntent(intent)', + ' }', + '}', + '', + ].join('\n') + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('already overrides onNewIntent')) + }) + + it('patches when onNewIntent appears only inside a comment', () => { + const source = [ + 'class MainActivity : ReactActivity() {', + ' // override fun onNewIntent(intent: Intent) {}', + ' override fun getMainComponentName(): String = "main"', + '}', + '', + ].join('\n') + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toContain('setIntent(intent)') + expect(console.warn).not.toHaveBeenCalled() + }) + + it('refuses a file whose block comment never closes', () => { + const source = ['class MainActivity : ReactActivity() {', ' /* never closed', ' fun x() {}', ''].join('\n') + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find the MainActivity class body')) + }) + + it('leaves a Java file whose text block contains a brace', () => { + const source = [ + 'class MainActivity extends ReactActivity {', + ' String s = """', + ' }', + ' """;', + ' @Override', + ' public void onNewIntent(android.content.Intent intent) {', + ' super.onNewIntent(intent);', + ' }', + '}', + '', + ].join('\n') + + expect(updateMainActivityNewIntentOverride(source, 'java', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('already overrides onNewIntent')) + }) + + it('leaves a Kotlin file whose existing override sits below a nested block comment', () => { + const source = [ + 'import com.facebook.react.ReactActivity', + 'class MainActivity : ReactActivity() {', + ' /*', + ' fun retiredHandler() {', + ' /* retired implementation */', + ' }', + ' */', + ' override fun onNewIntent(intent: android.content.Intent) {', + ' super.onNewIntent(intent)', + ' }', + '}', + '', + ].join('\n') + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('already overrides onNewIntent')) + }) + + it('treats a Java block comment as ending at the first close', () => { + const source = [ + 'class MainActivity extends ReactActivity {', + ' /* outer /* inner */', + ' @Override', + ' public void onNewIntent(android.content.Intent intent) {', + ' super.onNewIntent(intent);', + ' }', + '}', + '', + ].join('\n') + + expect(updateMainActivityNewIntentOverride(source, 'java', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('already overrides onNewIntent')) + }) + + it('patches MainActivity when a later class in the same file overrides onNewIntent', () => { + const source = `${kotlinMainActivity}\nclass Helper {\n fun onNewIntent(intent: Intent) {}\n}\n` + const result = updateMainActivityNewIntentOverride(source, 'kt', true) + + expect(result.indexOf('setIntent(intent)')).toBeLessThan(result.indexOf('class Helper')) + expect(console.warn).not.toHaveBeenCalled() + }) + + // A `"}"` field would end the class early for a scanner that counts every brace, hiding the real + // override from the scoped check, so the second override we then insert breaks the build. + it.each([ + [ + 'kt', + kotlinMainActivity.replace( + ' override fun getMainComponentName', + ' private val closing = "}"\n\n override fun onNewIntent(intent: Intent) {\n super.onNewIntent(intent)\n }\n\n override fun getMainComponentName' + ), + ], + [ + 'java', + javaMainActivity.replace( + ' @Override\n protected String getMainComponentName', + ' private final String closing = "}";\n\n @Override\n public void onNewIntent(Intent intent) {\n super.onNewIntent(intent);\n }\n\n @Override\n protected String getMainComponentName' + ), + ], + ])('keeps an existing %s override that follows a "}" string literal', (language, source) => { + expect(updateMainActivityNewIntentOverride(source, language, true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('already overrides onNewIntent')) + }) + + it.each([ + [ + 'kt', + kotlinMainActivity.replace( + ' override fun getMainComponentName', + [ + ' private val open = "{"', + " private val char = '{'", + ' private val raw = """}"""', + ' private val template = "${ "}" }" // }', + ' /* { */', + ' override fun getMainComponentName', + ].join('\n') + ), + ], + [ + 'java', + javaMainActivity.replace( + ' @Override\n protected String getMainComponentName', + [ + ' private final String open = "{";', + " private final char c = '{';", + ' private final String escaped = "\\\\{\\"}";', + ' // }', + ' /* { */', + ' @Override\n protected String getMainComponentName', + ].join('\n') + ), + ], + ])('patches a %s file whose literals and comments contain lone braces', (language, source) => { + const result = updateMainActivityNewIntentOverride(source, language, true) + + expect(result).toContain('setIntent(intent)') + expect(result.indexOf('onNewIntent')).toBeLessThan(result.indexOf('getMainComponentName')) + expect(updateMainActivityNewIntentOverride(result, language, false)).toBe(source) + expect(console.warn).not.toHaveBeenCalled() + }) + + it('skips a file with an unterminated string rather than guessing where the class ends', () => { + const source = kotlinMainActivity.replace( + ' override fun getMainComponentName', + ' private val broken = "}\n override fun getMainComponentName' + ) + + expect(updateMainActivityNewIntentOverride(source, 'kt', true)).toBe(source) + expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('Could not find the MainActivity class body')) + }) +}) + describe('postHogExpoPlugin Android native symbols', () => { const projectRoots: string[] = [] const projectBuildGradle = [ @@ -941,10 +1242,12 @@ describe('postHogExpoPlugin Android native symbols', () => { const androidRoot = path.join(projectRoot, 'android') const appRoot = path.join(androidRoot, 'app') projectRoots.push(projectRoot) - fs.mkdirSync(appRoot, { recursive: true }) + const sourceRoot = path.join(appRoot, 'src/main/java/com/example') + fs.mkdirSync(sourceRoot, { recursive: true }) fs.writeFileSync(path.join(androidRoot, 'build.gradle'), projectContents) fs.writeFileSync(path.join(appRoot, 'build.gradle'), appBuildGradle) fs.writeFileSync(path.join(androidRoot, 'gradle.properties'), '') + fs.writeFileSync(path.join(sourceRoot, 'MainActivity.kt'), kotlinMainActivity) const withEarlierAppGradlePlugin = withAppBuildGradle( { name: 'PostHog config plugin test', slug: 'posthog-config-plugin-test' } as any, @@ -965,7 +1268,13 @@ describe('postHogExpoPlugin Android native symbols', () => { } } + beforeEach(() => { + // The MainActivity patch announces itself on every first prebuild. + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + vi.restoreAllMocks() for (const projectRoot of projectRoots.splice(0)) { fs.rmSync(projectRoot, { recursive: true, force: true }) }