From 57de57ce6aad5483ef4e293ad9c7a17ea8a8b774 Mon Sep 17 00:00:00 2001 From: Hailey Date: Fri, 14 Jun 2024 00:40:47 -0700 Subject: [PATCH] referrers for all platforms --- app.config.js | 1 + .../expo-get-referrer/android/build.gradle | 47 ++++++++++ .../android/src/main/AndroidManifest.xml | 2 + .../getreferrer/ExpoGetReferrerModule.kt | 86 +++++++++++++++++++ .../expo-get-referrer/expo-module.config.json | 9 ++ modules/expo-get-referrer/index.ts | 33 +++++++ .../ios/ExpoGetReferrer.podspec | 21 +++++ .../ios/ExpoGetReferrerModule.swift | 37 ++++++++ .../src/ExpoGetReferrer.types.ts | 15 ++++ .../src/ExpoGetReferrerModule.ios.ts | 14 +++ .../src/ExpoGetReferrerModule.ts | 11 +++ .../src/ExpoGetReferrerModule.web.ts | 30 +++++++ plugins/withAppDelegateReferrer.js | 41 +++++++++ src/Navigation.tsx | 14 ++- src/lib/hooks/useIntentHandler.ts | 16 +++- src/lib/statsig/events.ts | 4 + src/lib/statsig/statsig.tsx | 21 ----- 17 files changed, 379 insertions(+), 23 deletions(-) create mode 100644 modules/expo-get-referrer/android/build.gradle create mode 100644 modules/expo-get-referrer/android/src/main/AndroidManifest.xml create mode 100644 modules/expo-get-referrer/android/src/main/java/expo/modules/getreferrer/ExpoGetReferrerModule.kt create mode 100644 modules/expo-get-referrer/expo-module.config.json create mode 100644 modules/expo-get-referrer/index.ts create mode 100644 modules/expo-get-referrer/ios/ExpoGetReferrer.podspec create mode 100644 modules/expo-get-referrer/ios/ExpoGetReferrerModule.swift create mode 100644 modules/expo-get-referrer/src/ExpoGetReferrer.types.ts create mode 100644 modules/expo-get-referrer/src/ExpoGetReferrerModule.ios.ts create mode 100644 modules/expo-get-referrer/src/ExpoGetReferrerModule.ts create mode 100644 modules/expo-get-referrer/src/ExpoGetReferrerModule.web.ts create mode 100644 plugins/withAppDelegateReferrer.js diff --git a/app.config.js b/app.config.js index eafacc6cc1..04b0df704c 100644 --- a/app.config.js +++ b/app.config.js @@ -209,6 +209,7 @@ module.exports = function (config) { './plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js', './plugins/shareExtension/withShareExtensions.js', './plugins/notificationsExtension/withNotificationsExtension.js', + './plugins/withAppDelegateReferrer.js', ].filter(Boolean), extra: { eas: { diff --git a/modules/expo-get-referrer/android/build.gradle b/modules/expo-get-referrer/android/build.gradle new file mode 100644 index 0000000000..23c21076a3 --- /dev/null +++ b/modules/expo-get-referrer/android/build.gradle @@ -0,0 +1,47 @@ +apply plugin: 'com.android.library' + +group = 'expo.modules.getreferrer' +version = '0.6.0' + +def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") +apply from: expoModulesCorePlugin +applyKotlinExpoModulesCorePlugin() +useCoreDependencies() +useExpoPublishing() + +// If you want to use the managed Android SDK versions from expo-modules-core, set this to true. +// The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. +// Most of the time, you may like to manage the Android SDK versions yourself. +def useManagedAndroidSdkVersions = false +if (useManagedAndroidSdkVersions) { + useDefaultAndroidSdkVersions() +} else { + buildscript { + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + } + project.android { + compileSdkVersion safeExtGet("compileSdkVersion", 34) + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) + } + } +} + +android { + namespace "expo.modules.getreferrer" + defaultConfig { + versionCode 1 + versionName "0.6.0" + } + lintOptions { + abortOnError false + } +} + +dependencies { + implementation("com.android.installreferrer:installreferrer:2.2") +} diff --git a/modules/expo-get-referrer/android/src/main/AndroidManifest.xml b/modules/expo-get-referrer/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..bdae66c8f5 --- /dev/null +++ b/modules/expo-get-referrer/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/modules/expo-get-referrer/android/src/main/java/expo/modules/getreferrer/ExpoGetReferrerModule.kt b/modules/expo-get-referrer/android/src/main/java/expo/modules/getreferrer/ExpoGetReferrerModule.kt new file mode 100644 index 0000000000..3a95f2977b --- /dev/null +++ b/modules/expo-get-referrer/android/src/main/java/expo/modules/getreferrer/ExpoGetReferrerModule.kt @@ -0,0 +1,86 @@ +package expo.modules.getreferrer + +import android.content.Intent +import android.net.Uri +import android.os.Build +import com.android.installreferrer.api.InstallReferrerClient +import com.android.installreferrer.api.InstallReferrerStateListener +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import expo.modules.kotlin.Promise + +class ExpoGetReferrerModule : Module() { + private var intent: Intent? = null + private var referrer: Uri? = null + + override fun definition() = ModuleDefinition { + Name("ExpoGetReferrer") + + OnNewIntent { + intent = it + referrer = appContext.currentActivity?.referrer + } + + AsyncFunction("getReferrerInfoAsync") { + val intentReferrer = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent?.getParcelableExtra(Intent.EXTRA_REFERRER, Uri::class.java) + } else { + intent?.getParcelableExtra(Intent.EXTRA_REFERRER) + } + + // Some apps explicitly set a referrer, like Chrome. In these cases, we prefer this since + // it's the actual website that the user came from rather than the app. + if (intentReferrer is Uri) { + return@AsyncFunction mapOf( + "referrer" to intentReferrer.toString(), + "hostname" to intentReferrer.host, + ) + } + + // In all other cases, we'll just record the app that sent the intent. + if (referrer != null) { + // referrer could become null here. `.toString()` though can be called on null + return@AsyncFunction mapOf( + "referrer" to referrer.toString(), + "hostname" to (referrer?.host ?: ""), + ) + } + + return@AsyncFunction null + } + + AsyncFunction("getGooglePlayReferrerInfoAsync") { promise: Promise -> + val referrerClient = InstallReferrerClient.newBuilder(appContext.reactContext).build() + referrerClient.startConnection(object : InstallReferrerStateListener { + override fun onInstallReferrerSetupFinished(responseCode: Int) { + if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) { + val response = referrerClient.installReferrer + promise.resolve( + mapOf( + "installReferrer" to response.installReferrer, + "clickTimestamp" to response.referrerClickTimestampSeconds, + "installTimestamp" to response.installBeginTimestampSeconds + ) + ) + } else { + promise.reject( + "ERR_GOOGLE_PLAY_REFERRER_UNKNOWN", + "Failed to get referrer info", + Exception("Failed to get referrer info") + ) + } + referrerClient.endConnection() + } + + override fun onInstallReferrerServiceDisconnected() { + promise.reject( + "ERR_GOOGLE_PLAY_REFERRER_DISCONNECTED", + "Failed to get referrer info", + Exception("Failed to get referrer info") + ) + referrerClient.endConnection() + } + }) + } + } +} diff --git a/modules/expo-get-referrer/expo-module.config.json b/modules/expo-get-referrer/expo-module.config.json new file mode 100644 index 0000000000..2ccef0869e --- /dev/null +++ b/modules/expo-get-referrer/expo-module.config.json @@ -0,0 +1,9 @@ +{ + "platforms": ["ios", "tvos", "android", "web"], + "ios": { + "modules": ["ExpoGetReferrerModule"] + }, + "android": { + "modules": ["expo.modules.getreferrer.ExpoGetReferrerModule"] + } +} diff --git a/modules/expo-get-referrer/index.ts b/modules/expo-get-referrer/index.ts new file mode 100644 index 0000000000..f4453a954c --- /dev/null +++ b/modules/expo-get-referrer/index.ts @@ -0,0 +1,33 @@ +import {EventEmitter, NativeModulesProxy, Subscription} from 'expo-modules-core' + +import { + ChangeEventPayload, + ExpoGetReferrerViewProps, +} from './src/ExpoGetReferrer.types' +// Import the native module. On web, it will be resolved to ExpoGetReferrer.web.ts +// and on native platforms to ExpoGetReferrer.ts +import ExpoGetReferrerModule from './src/ExpoGetReferrerModule' +import ExpoGetReferrerView from './src/ExpoGetReferrerView' + +// Get the native constant value. +export const PI = ExpoGetReferrerModule.PI + +export function hello(): string { + return ExpoGetReferrerModule.hello() +} + +export async function setValueAsync(value: string) { + return await ExpoGetReferrerModule.setValueAsync(value) +} + +const emitter = new EventEmitter( + ExpoGetReferrerModule ?? NativeModulesProxy.ExpoGetReferrer, +) + +export function addChangeListener( + listener: (event: ChangeEventPayload) => void, +): Subscription { + return emitter.addListener('onChange', listener) +} + +export {ChangeEventPayload, ExpoGetReferrerView, ExpoGetReferrerViewProps} diff --git a/modules/expo-get-referrer/ios/ExpoGetReferrer.podspec b/modules/expo-get-referrer/ios/ExpoGetReferrer.podspec new file mode 100644 index 0000000000..a99d15804b --- /dev/null +++ b/modules/expo-get-referrer/ios/ExpoGetReferrer.podspec @@ -0,0 +1,21 @@ +Pod::Spec.new do |s| + s.name = 'ExpoGetReferrer' + s.version = '1.0.0' + s.summary = 'A sample project summary' + s.description = 'A sample project description' + s.author = '' + s.homepage = 'https://docs.expo.dev/modules/' + s.platforms = { :ios => '13.4', :tvos => '13.4' } + s.source = { git: '' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + # Swift/Objective-C compatibility + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" +end diff --git a/modules/expo-get-referrer/ios/ExpoGetReferrerModule.swift b/modules/expo-get-referrer/ios/ExpoGetReferrerModule.swift new file mode 100644 index 0000000000..ef45da7e95 --- /dev/null +++ b/modules/expo-get-referrer/ios/ExpoGetReferrerModule.swift @@ -0,0 +1,37 @@ +import UIKit +import ExpoModulesCore + +public class ExpoGetReferrerModule: Module { + public func definition() -> ModuleDefinition { + Name("ExpoGetReferrer") + + AsyncFunction("getReferrerInfoAsync") { (promise: Promise) in + let defaults = UserDefaults.standard + let referrerUrlString = defaults.string(forKey: "referrer") + let referrerApp = defaults.string(forKey: "referrerApp") + + if let referrerUrlString = defaults.string(forKey: "referrer"), + let url = URL(string: referrerUrlString) + { + if #available(iOS 16.0, *) { + promise.resolve([ + "referrer": url.absoluteString, + "hostname": url.host() ?? "" + ]) + } else { + promise.resolve([ + "referrer": url.absoluteString, + "hostname": url.host ?? "" + ]) + } + } else if let referrerApp = defaults.string(forKey: "referrerApp") { + promise.resolve([ + "referrer": referrerApp, + "hostname": referrerApp, + ]) + } else { + promise.resolve(nil) + } + } + } +} diff --git a/modules/expo-get-referrer/src/ExpoGetReferrer.types.ts b/modules/expo-get-referrer/src/ExpoGetReferrer.types.ts new file mode 100644 index 0000000000..a58e1d7935 --- /dev/null +++ b/modules/expo-get-referrer/src/ExpoGetReferrer.types.ts @@ -0,0 +1,15 @@ +export type ExpoGetReferrerModule = { + getGooglePlayReferrerInfoAsync: () => Promise + getReferrerInfoAsync: () => Promise<{ + referrer: string + hostname: string + } | null> +} + +export type GooglePlayReferrerInfo = + | { + installReferrer?: string + clickTimestamp?: number + installTimestamp?: number + } + | undefined diff --git a/modules/expo-get-referrer/src/ExpoGetReferrerModule.ios.ts b/modules/expo-get-referrer/src/ExpoGetReferrerModule.ios.ts new file mode 100644 index 0000000000..08b769ed3c --- /dev/null +++ b/modules/expo-get-referrer/src/ExpoGetReferrerModule.ios.ts @@ -0,0 +1,14 @@ +import {requireNativeModule} from 'expo-modules-core' + +import {ExpoGetReferrerModule} from './ExpoGetReferrer.types' + +const NativeModule = + requireNativeModule('ExpoGetReferrer') + +export const GetReferrerModule: ExpoGetReferrerModule = { + getGooglePlayReferrerInfoAsync: async () => { + console.error('getGooglePlayReferrerInfo is only available on Android') + throw new Error('getGooglePlayReferrerInfo is only available on Android') + }, + getReferrerInfoAsync: NativeModule.getReferrerInfoAsync, +} diff --git a/modules/expo-get-referrer/src/ExpoGetReferrerModule.ts b/modules/expo-get-referrer/src/ExpoGetReferrerModule.ts new file mode 100644 index 0000000000..a760e3e138 --- /dev/null +++ b/modules/expo-get-referrer/src/ExpoGetReferrerModule.ts @@ -0,0 +1,11 @@ +import {requireNativeModule} from 'expo-modules-core' + +import {ExpoGetReferrerModule} from './ExpoGetReferrer.types' + +const NativeModule = + requireNativeModule('ExpoGetReferrer') + +export const GetReferrerModule: ExpoGetReferrerModule = { + getGooglePlayReferrerInfoAsync: NativeModule.getGooglePlayReferrerInfoAsync, + getReferrerInfoAsync: NativeModule.getReferrerInfoAsync, +} diff --git a/modules/expo-get-referrer/src/ExpoGetReferrerModule.web.ts b/modules/expo-get-referrer/src/ExpoGetReferrerModule.web.ts new file mode 100644 index 0000000000..265b097231 --- /dev/null +++ b/modules/expo-get-referrer/src/ExpoGetReferrerModule.web.ts @@ -0,0 +1,30 @@ +import {isWeb} from 'platform/detection' +import {ExpoGetReferrerModule} from './ExpoGetReferrer.types' + +export const GetReferrerModule: ExpoGetReferrerModule = { + getGooglePlayReferrerInfoAsync: async () => { + console.error('getReferrerInfoAsync is only available on Android') + throw new Error('getGooglePlayReferrerInfo is only available on Android') + }, + getReferrerInfoAsync: async () => { + try { + if ( + isWeb && + typeof document !== 'undefined' && + document != null && + document.referrer + ) { + const url = new URL(document.referrer) + if (url.hostname !== 'bsky.app') { + return { + referrer: url.href, + hostname: url.hostname, + } + } + } + } catch { + return null + } + return null + }, +} diff --git a/plugins/withAppDelegateReferrer.js b/plugins/withAppDelegateReferrer.js new file mode 100644 index 0000000000..de773df075 --- /dev/null +++ b/plugins/withAppDelegateReferrer.js @@ -0,0 +1,41 @@ +const {withAppDelegate} = require('@expo/config-plugins') +const {mergeContents} = require('@expo/config-plugins/build/utils/generateCode') +const path = require('path') +const fs = require('fs') + +module.exports = config => { + // eslint-disable-next-line no-shadow + return withAppDelegate(config, async config => { + const delegatePath = path.join( + config.modRequest.platformProjectRoot, + 'AppDelegate.mm', + ) + + let newContents = config.modResults.contents + newContents = mergeContents({ + src: newContents, + anchor: '// Linking API', + newSrc: ` + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + [defaults setObject:options[UIApplicationOpenURLOptionsSourceApplicationKey] forKey:@"referrerApp"];\n`, + offset: 2, + tag: 'referrer info - deep links', + comment: '//', + }).contents + + newContents = mergeContents({ + src: newContents, + anchor: '// Universal Links', + newSrc: ` + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + [defaults setURL:userActivity.referrerURL forKey:@"referrer"];\n`, + offset: 2, + tag: 'referrer info - universal links', + comment: '//', + }).contents + + config.modResults.contents = newContents + + return config + }) +} diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 67b89e2627..dccfd1dc04 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -31,7 +31,7 @@ import { } from 'lib/routes/types' import {RouteParams, State} from 'lib/routes/types' import {bskyTitle} from 'lib/strings/headings' -import {isAndroid, isNative} from 'platform/detection' +import {isAndroid, isNative, isWeb} from 'platform/detection' import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' import {AppPasswords} from 'view/screens/AppPasswords' import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' @@ -43,6 +43,7 @@ import HashtagScreen from '#/screens/Hashtag' import {ModerationScreen} from '#/screens/Moderation' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' +import {GetReferrerModule} from '../modules/expo-get-referrer/src/ExpoGetReferrerModule' import {init as initAnalytics} from './lib/analytics/analytics' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' @@ -728,6 +729,17 @@ function logModuleInitTime() { initMs, }) + if (isWeb) { + GetReferrerModule.getReferrerInfoAsync().then(info => { + if (info && info.hostname !== 'bsky.app') { + logEvent('deepLink:referrerReceived', { + referrer: info?.referrer, + hostname: info?.hostname, + }) + } + }) + } + if (__DEV__) { // This log is noisy, so keep false committed const shouldLog = false diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts index 8741530b50..dd7036881e 100644 --- a/src/lib/hooks/useIntentHandler.ts +++ b/src/lib/hooks/useIntentHandler.ts @@ -1,9 +1,12 @@ import React from 'react' import * as Linking from 'expo-linking' + +import {logEvent} from 'lib/statsig/statsig' import {isNative} from 'platform/detection' -import {useComposerControls} from 'state/shell' import {useSession} from 'state/session' +import {useComposerControls} from 'state/shell' import {useCloseAllActiveElements} from 'state/util' +import {GetReferrerModule} from '../../../modules/expo-get-referrer/src/ExpoGetReferrerModule' type IntentType = 'compose' @@ -15,6 +18,17 @@ export function useIntentHandler() { React.useEffect(() => { const handleIncomingURL = (url: string) => { + GetReferrerModule.getReferrerInfoAsync().then(info => { + console.log(info) + + if (info && info.hostname !== 'bsky.app') { + logEvent('deepLink:referrerReceived', { + referrer: info?.referrer, + hostname: info?.hostname, + }) + } + }) + // We want to be able to support bluesky:// deeplinks. It's unnatural for someone to use a deeplink with three // slashes, like bluesky:///intent/follow. However, supporting just two slashes causes us to have to take care // of two cases when parsing the url. If we ensure there is a third slash, we can always ensure the first diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts index 9939f60c9c..a70ce9c76a 100644 --- a/src/lib/statsig/events.ts +++ b/src/lib/statsig/events.ts @@ -25,6 +25,10 @@ export type LogEvents = { } 'state:foreground:sampled': {} 'router:navigate:sampled': {} + 'deepLink:referrerReceived': { + referrer: string + hostname: string + } // Screen events 'splash:signInPressed': {} diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index f6aed999f9..166d468a1b 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -26,8 +26,6 @@ type StatsigUser = { bundleDate: number refSrc: string refUrl: string - referrer: string - referrerHostname: string appLanguage: string contentLanguages: string[] } @@ -35,29 +33,12 @@ type StatsigUser = { let refSrc = '' let refUrl = '' -let referrer = '' -let referrerHostname = '' if (isWeb && typeof window !== 'undefined') { const params = new URLSearchParams(window.location.search) refSrc = params.get('ref_src') ?? '' refUrl = decodeURIComponent(params.get('ref_url') ?? '') } -if ( - isWeb && - typeof document !== 'undefined' && - document != null && - document.referrer -) { - try { - const url = new URL(document.referrer) - if (url.hostname !== 'bsky.app') { - referrer = document.referrer - referrerHostname = url.hostname - } - } catch {} -} - export type {LogEvents} function createStatsigOptions(prefetchUsers: StatsigUser[]) { @@ -217,8 +198,6 @@ function toStatsigUser(did: string | undefined): StatsigUser { custom: { refSrc, refUrl, - referrer, - referrerHostname, platform: Platform.OS as 'ios' | 'android' | 'web', bundleIdentifier: BUNDLE_IDENTIFIER, bundleDate: BUNDLE_DATE,