diff --git a/modules/expo-bluesky-swiss-army/android/build.gradle b/modules/expo-bluesky-swiss-army/android/build.gradle
new file mode 100644
index 0000000000..4a823450c7
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/build.gradle
@@ -0,0 +1,43 @@
+apply plugin: 'com.android.library'
+
+group = 'expo.modules.blueskyswissarmy'
+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.blueskyswissarmy"
+ defaultConfig {
+ versionCode 1
+ versionName "0.6.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..bdae66c8f5
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/ExpoBlueskySwissArmyModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/ExpoBlueskySwissArmyModule.kt
new file mode 100644
index 0000000000..0410240ff3
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/ExpoBlueskySwissArmyModule.kt
@@ -0,0 +1,10 @@
+package expo.modules.blueskyswissarmy
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBlueskySwissArmyModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("ExpoBlueskySwissArmy")
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json
new file mode 100644
index 0000000000..74e6a561ad
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+ "platforms": ["ios", "tvos", "android", "web"],
+ "ios": {
+ "modules": ["ExpoBlueskySwissArmyModule"]
+ },
+ "android": {
+ "modules": ["expo.modules.blueskyswissarmy.ExpoBlueskySwissArmyModule"]
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts
new file mode 100644
index 0000000000..e72eae7dfd
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/index.ts
@@ -0,0 +1,2 @@
+import {ExpoBlueskySwissArmyModule} from './src/ExpoBlueskySwissArmy'
+export {ExpoBlueskySwissArmyModule as SwissArmyKnife}
diff --git a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec
new file mode 100644
index 0000000000..be4b0eae45
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+ s.name = 'ExpoBlueskySwissArmy'
+ s.version = '1.0.0'
+ s.summary = 'A collection of native tools for Bluesky'
+ s.description = 'A collection of native tools for Bluesky'
+ s.author = ''
+ s.homepage = 'https://github.com/bluesky-social/social-app'
+ 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-bluesky-swiss-army/ios/ExpoBlueskySwissArmyModule.swift b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmyModule.swift
new file mode 100644
index 0000000000..ddf7f5055c
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmyModule.swift
@@ -0,0 +1,26 @@
+import ExpoModulesCore
+
+public class ExpoBlueskySwissArmyModule: Module {
+ func getDefaults(_ useAppGroup: Bool) -> UserDefaults? {
+ if useAppGroup {
+ return UserDefaults(suiteName: "group.app.bsky")
+ } else {
+ return UserDefaults.standard
+ }
+ }
+
+ public func definition() -> ModuleDefinition {
+ Name("ExpoBlueskySwissArmy")
+
+ AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in
+ let defaults = self.getDefaults(useAppGroup)
+
+ return defaults?.string(forKey: key)
+ }
+
+ AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in
+ let defaults = self.getDefaults(useAppGroup)
+ defaults?.setValue(value, forKey: key)
+ }
+ }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmy.ts b/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmy.ts
new file mode 100644
index 0000000000..30b75b856f
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmy.ts
@@ -0,0 +1,13 @@
+type ExpoBlueskySwissArmyModuleType = {
+ getStringValueAsync(
+ key: string,
+ useAppGroup?: boolean,
+ ): Promise
+ setStringValueAsync(
+ key: string,
+ value: string | null,
+ useAppGroup?: boolean,
+ ): Promise
+}
+
+export const ExpoBlueskySwissArmyModule = {} as ExpoBlueskySwissArmyModuleType
diff --git a/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.android.ts b/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.android.ts
new file mode 100644
index 0000000000..cd3f9e59c1
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.android.ts
@@ -0,0 +1,8 @@
+export const ExpoBlueskySwissArmyModule = {
+ getStringValueAsync(_: string, _?: boolean): Promise {
+ throw new Error('getStringValueAsync is not available on Android')
+ },
+ setStringValueAsync(_: string, _: string | null, _?: boolean): Promise {
+ throw new Error('setStringValueAsync is not available on Android')
+ },
+}
diff --git a/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.ios.ts b/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.ios.ts
new file mode 100644
index 0000000000..d1c2b696e5
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.ios.ts
@@ -0,0 +1,16 @@
+import {requireNativeModule} from 'expo-modules-core'
+
+const NativeModule = requireNativeModule('ExpoBlueskySwissArmy')
+
+export const ExpoBlueskySwissArmyModule = {
+ getStringValueAsync(key: string, useAppGroup = false): Promise {
+ return NativeModule.getStringValueAsync(key, useAppGroup)
+ },
+ setStringValueAsync(
+ key: string,
+ value: string | null,
+ useAppGroup = false,
+ ): Promise {
+ return NativeModule.setStringValueAsync(key, value, useAppGroup)
+ },
+}
diff --git a/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.web.ts b/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.web.ts
new file mode 100644
index 0000000000..8068da1407
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/ExpoBlueskySwissArmyModule.web.ts
@@ -0,0 +1,8 @@
+export const ExpoBlueskySwissArmyModule = {
+ getStringValueAsync(_: string, _?: boolean): Promise {
+ throw new Error('getStringValueAsync is not available on web')
+ },
+ setStringValueAsync(_: string, _: string | null, _?: boolean): Promise {
+ throw new Error('setStringValueAsync is not available on web')
+ },
+}
diff --git a/src/components/hooks/useStarterPackEntry.android.ts b/src/components/hooks/useStarterPackEntry.android.ts
deleted file mode 100644
index 09664db1b1..0000000000
--- a/src/components/hooks/useStarterPackEntry.android.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import React from 'react'
-
-import {makeStarterPackLink} from 'lib/routes/links'
-import {useSetCurrentStarterPack} from 'state/preferences/starter-pack'
-import {useUsedStarterPacks} from 'state/preferences/used-starter-packs'
-import GooglePlayReferrer from '../../../modules/expo-google-play-referrer'
-
-export function useStarterPackEntry() {
- const [ready, setReady] = React.useState(false)
- const setCurrentStarterPack = useSetCurrentStarterPack()
- const usedStarterPacks = useUsedStarterPacks()
-
- React.useEffect(() => {
- if (ready) return
- ;(async () => {
- const res = await GooglePlayReferrer.getReferrerInfoAsync()
-
- if (res && res.installReferrer) {
- // The `utm_content` parameter should be `starterpack--rkey>`. Let's try to get it from the parameters
- // and create a URI for it if it's valid.
- const parts = res.installReferrer.split('&')
- const starterPackSource = parts
- .find(part => part.startsWith('utm_content='))
- ?.split('=')[1]
- const sourceParts = starterPackSource?.split('-')
-
- if (sourceParts?.length === 3 && sourceParts[0] === 'starterpack') {
- // Android doesn't clear the referrer info for a total of 90 days. We want to make sure that we don't
- // retrigger this on accident.
- // We won't actually set `lastUsedUri` until a _successful_ use of the starter pack, meaning a new account
- // has actually completed onboarding with the given starter pack.
- const uri = makeStarterPackLink(sourceParts[1], sourceParts[2])
- if (!usedStarterPacks?.includes(uri)) {
- setCurrentStarterPack({
- uri: makeStarterPackLink(sourceParts[1], sourceParts[2]),
- })
- }
- }
- }
- setReady(true)
- })()
- }, [ready, setCurrentStarterPack, usedStarterPacks])
-
- return ready
-}
diff --git a/src/components/hooks/useStarterPackEntry.ios.ts b/src/components/hooks/useStarterPackEntry.ios.ts
deleted file mode 100644
index 5ac77efddb..0000000000
--- a/src/components/hooks/useStarterPackEntry.ios.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react'
-
-export function useStarterPackEntry() {
- const [ready, setReady] = React.useState(false)
-
- React.useEffect(() => {
- ;(async () => {
- setReady(true)
- })()
- }, [])
-
- return ready
-}
diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts
new file mode 100644
index 0000000000..35711c2b95
--- /dev/null
+++ b/src/components/hooks/useStarterPackEntry.native.ts
@@ -0,0 +1,48 @@
+import React from 'react'
+
+import {makeStarterPackLink} from 'lib/routes/links'
+import {isAndroid} from 'platform/detection'
+import {useSetCurrentStarterPack} from 'state/preferences/starter-pack'
+import {useUsedStarterPacks} from 'state/preferences/used-starter-packs'
+import {SwissArmyKnife} from '../../../modules/expo-bluesky-swiss-army'
+import GooglePlayReferrer from '../../../modules/expo-google-play-referrer'
+
+export function useStarterPackEntry() {
+ const [ready, setReady] = React.useState(false)
+ const setCurrentStarterPack = useSetCurrentStarterPack()
+ const usedStarterPacks = useUsedStarterPacks()
+
+ React.useEffect(() => {
+ if (ready) return
+ ;(async () => {
+ let uri: string | null | undefined
+
+ if (isAndroid) {
+ const res = await GooglePlayReferrer.getReferrerInfoAsync()
+
+ if (res && res.installReferrer) {
+ const parts = res.installReferrer.split('&')
+ const starterPackSource = parts
+ .find(part => part.startsWith('utm_content='))
+ ?.split('=')[1]
+ const sourceParts = starterPackSource?.split('-')
+ if (sourceParts?.length === 3 && sourceParts[0] === 'starterpack') {
+ uri = makeStarterPackLink(sourceParts[1], sourceParts[2])
+ }
+ }
+ } else {
+ uri = await SwissArmyKnife.getStringValueAsync('starterPackUri', true)
+ }
+
+ if (uri && !usedStarterPacks?.includes(uri)) {
+ setCurrentStarterPack({
+ uri,
+ })
+ }
+
+ setReady(true)
+ })()
+ }, [ready, setCurrentStarterPack, usedStarterPacks])
+
+ return ready
+}