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,