about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--__e2e__/flows/shared-prefs.yml31
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt11
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/ExpoBlueskySharedPrefsModule.kt69
-rw-r--r--modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/SharedPrefs.kt241
-rw-r--r--modules/expo-bluesky-swiss-army/expo-module.config.json4
-rw-r--r--modules/expo-bluesky-swiss-army/index.ts4
-rw-r--r--modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift23
-rw-r--r--modules/expo-bluesky-swiss-army/ios/SharedPrefs/ExpoBlueskySharedPrefsModule.swift62
-rw-r--r--modules/expo-bluesky-swiss-army/ios/SharedPrefs/SharedPrefs.swift89
-rw-r--r--modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts18
-rw-r--r--modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts16
-rw-r--r--modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts51
-rw-r--r--modules/expo-bluesky-swiss-army/src/SharedPrefs/index.ts36
-rw-r--r--package.json1
-rw-r--r--src/Navigation.tsx6
-rw-r--r--src/components/hooks/useStarterPackEntry.native.ts14
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/screens/E2E/SharedPreferencesTesterScreen.tsx113
-rw-r--r--src/view/screens/Storybook/Dialogs.tsx13
19 files changed, 722 insertions, 81 deletions
diff --git a/__e2e__/flows/shared-prefs.yml b/__e2e__/flows/shared-prefs.yml
new file mode 100644
index 000000000..73a066829
--- /dev/null
+++ b/__e2e__/flows/shared-prefs.yml
@@ -0,0 +1,31 @@
+appId: xyz.blueskyweb.app
+---
+- runScript:
+    file: ../setupServer.js
+    env:
+      SERVER_PATH: "?users&posts&feeds"
+- runFlow:
+    file: ../setupApp.yml
+- tapOn:
+    id: "e2eSignInAlice"
+- tapOn: "/sys/debug"
+- tapOn:
+    id: "sharedPrefsTestOpenBtn"
+- tapOn:
+    id: "setStringBtn"
+- assertVisible: "Hello"
+- tapOn:
+    id: "removeStringBtn"
+- assertVisible: "null"
+- tapOn:
+    id: "setBoolBtn"
+- assertVisible: "true"
+- tapOn:
+    id: "setNumberBtn"
+- assertVisible: "123"
+- tapOn:
+    id: "addToSetBtn"
+- assertVisible: "true"
+- tapOn:
+    id: "removeFromSetBtn"
+- assertVisible: "false"
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt
deleted file mode 100644
index 51f9fe45d..000000000
--- a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/deviceprefs/ExpoBlueskyDevicePrefsModule.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package expo.modules.blueskyswissarmy.deviceprefs
-
-import expo.modules.kotlin.modules.Module
-import expo.modules.kotlin.modules.ModuleDefinition
-
-class ExpoBlueskyDevicePrefsModule : Module() {
-  override fun definition() =
-    ModuleDefinition {
-      Name("ExpoBlueskyDevicePrefs")
-    }
-}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/ExpoBlueskySharedPrefsModule.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/ExpoBlueskySharedPrefsModule.kt
new file mode 100644
index 000000000..271956878
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/ExpoBlueskySharedPrefsModule.kt
@@ -0,0 +1,69 @@
+package expo.modules.blueskyswissarmy.sharedprefs
+
+import android.content.Context
+import android.util.Log
+import expo.modules.kotlin.jni.JavaScriptValue
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBlueskySharedPrefsModule : Module() {
+  private fun getContext(): Context {
+    val context = appContext.reactContext ?: throw Error("Context is null")
+    return context
+  }
+
+  override fun definition() =
+    ModuleDefinition {
+      Name("ExpoBlueskySharedPrefs")
+
+      Function("setString") { key: String, value: String ->
+        return@Function SharedPrefs(getContext()).setValue(key, value)
+      }
+
+      Function("setValue") { key: String, value: JavaScriptValue ->
+        val context = getContext()
+        Log.d("ExpoBlueskySharedPrefs", "Setting value for key: $key")
+        try {
+          if (value.isNumber()) {
+            SharedPrefs(context).setValue(key, value.getFloat())
+          } else if (value.isBool()) {
+            SharedPrefs(context).setValue(key, value.getBool())
+          } else if (value.isNull() || value.isUndefined()) {
+            SharedPrefs(context).removeValue(key)
+          } else {
+            Log.d(NAME, "Unsupported type: ${value.kind()}")
+          }
+        } catch (e: Error) {
+          Log.d(NAME, "Error setting value: $e")
+        }
+      }
+
+      Function("removeValue") { key: String ->
+        return@Function SharedPrefs(getContext()).removeValue(key)
+      }
+
+      Function("getString") { key: String ->
+        return@Function SharedPrefs(getContext()).getString(key)
+      }
+
+      Function("getNumber") { key: String ->
+        return@Function SharedPrefs(getContext()).getFloat(key)
+      }
+
+      Function("getBool") { key: String ->
+        return@Function SharedPrefs(getContext()).getBoolean(key)
+      }
+
+      Function("addToSet") { key: String, value: String ->
+        return@Function SharedPrefs(getContext()).addToSet(key, value)
+      }
+
+      Function("removeFromSet") { key: String, value: String ->
+        return@Function SharedPrefs(getContext()).removeFromSet(key, value)
+      }
+
+      Function("setContains") { key: String, value: String ->
+        return@Function SharedPrefs(getContext()).setContains(key, value)
+      }
+    }
+}
diff --git a/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/SharedPrefs.kt b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/SharedPrefs.kt
new file mode 100644
index 000000000..38d79abf1
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/android/src/main/java/expo/modules/blueskyswissarmy/sharedprefs/SharedPrefs.kt
@@ -0,0 +1,241 @@
+package expo.modules.blueskyswissarmy.sharedprefs
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.util.Log
+
+val DEFAULTS =
+  mapOf<String, Any>(
+    "playSoundChat" to true,
+    "playSoundFollow" to false,
+    "playSoundLike" to false,
+    "playSoundMention" to false,
+    "playSoundQuote" to false,
+    "playSoundReply" to false,
+    "playSoundRepost" to false,
+    "badgeCount" to 0,
+  )
+
+const val NAME = "SharedPrefs"
+
+class SharedPrefs(
+  private val context: Context,
+) {
+  companion object {
+    private var hasInitialized = false
+
+    private var instance: SharedPreferences? = null
+
+    fun getInstance(
+      context: Context,
+      info: String? = "(no info)",
+    ): SharedPreferences {
+      if (instance == null) {
+        Log.d(NAME, "No preferences instance found, creating one.")
+        instance = context.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE)
+      }
+
+      val safeInstance = instance ?: throw Error("Preferences is null: $info")
+
+      if (!hasInitialized) {
+        Log.d(NAME, "Preferences instance has not been initialized yet.")
+        initialize(safeInstance)
+        hasInitialized = true
+        Log.d(NAME, "Preferences instance has been initialized.")
+      }
+
+      return safeInstance
+    }
+
+    private fun initialize(instance: SharedPreferences) {
+      instance
+        .edit()
+        .apply {
+          DEFAULTS.forEach { (key, value) ->
+            if (instance.contains(key)) {
+              return@forEach
+            }
+
+            when (value) {
+              is Boolean -> {
+                putBoolean(key, value)
+              }
+
+              is String -> {
+                putString(key, value)
+              }
+
+              is Array<*> -> {
+                putStringSet(key, value.map { it.toString() }.toSet())
+              }
+
+              is Map<*, *> -> {
+                putStringSet(key, value.map { it.toString() }.toSet())
+              }
+            }
+          }
+        }.apply()
+    }
+  }
+
+  fun setValue(
+    key: String,
+    value: String,
+  ) {
+    val safeInstance = getInstance(context)
+    safeInstance
+      .edit()
+      .apply {
+        putString(key, value)
+      }.apply()
+  }
+
+  fun setValue(
+    key: String,
+    value: Float,
+  ) {
+    val safeInstance = getInstance(context)
+    safeInstance
+      .edit()
+      .apply {
+        putFloat(key, value)
+      }.apply()
+  }
+
+  fun setValue(
+    key: String,
+    value: Boolean,
+  ) {
+    val safeInstance = getInstance(context)
+    safeInstance
+      .edit()
+      .apply {
+        putBoolean(key, value)
+      }.apply()
+  }
+
+  fun setValue(
+    key: String,
+    value: Set<String>,
+  ) {
+    val safeInstance = getInstance(context)
+    safeInstance
+      .edit()
+      .apply {
+        putStringSet(key, value)
+      }.apply()
+  }
+
+  fun removeValue(key: String) {
+    val safeInstance = getInstance(context)
+    safeInstance
+      .edit()
+      .apply {
+        remove(key)
+      }.apply()
+  }
+
+  fun getString(key: String): String? {
+    val safeInstance = getInstance(context)
+    return safeInstance.getString(key, null)
+  }
+
+  fun getFloat(key: String): Float? {
+    val safeInstance = getInstance(context)
+    if (!safeInstance.contains(key)) {
+      return null
+    }
+    return safeInstance.getFloat(key, 0.0f)
+  }
+
+  @Suppress("ktlint:standard:function-naming")
+  fun _setAnyValue(
+    key: String,
+    value: Any,
+  ) {
+    val safeInstance = getInstance(context)
+    safeInstance
+      .edit()
+      .apply {
+        when (value) {
+          is String -> putString(key, value)
+          is Float -> putFloat(key, value)
+          is Boolean -> putBoolean(key, value)
+          is Set<*> -> putStringSet(key, value.map { it.toString() }.toSet())
+          else -> throw Error("Unsupported type: ${value::class.java}")
+        }
+      }.apply()
+  }
+
+  fun getBoolean(key: String): Boolean? {
+    val safeInstance = getInstance(context)
+    if (!safeInstance.contains(key)) {
+      return null
+    }
+    Log.d(NAME, "Getting boolean for key: $key")
+    val res = safeInstance.getBoolean(key, false)
+    Log.d(NAME, "Got boolean for key: $key, value: $res")
+    return res
+  }
+
+  fun addToSet(
+    key: String,
+    value: String,
+  ) {
+    val safeInstance = getInstance(context)
+    val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
+    val newSet =
+      set.toMutableSet().apply {
+        add(value)
+      }
+    safeInstance
+      .edit()
+      .apply {
+        putStringSet(key, newSet)
+      }.apply()
+  }
+
+  fun removeFromSet(
+    key: String,
+    value: String,
+  ) {
+    val safeInstance = getInstance(context)
+    val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
+    val newSet =
+      set.toMutableSet().apply {
+        remove(value)
+      }
+    safeInstance
+      .edit()
+      .apply {
+        putStringSet(key, newSet)
+      }.apply()
+  }
+
+  fun setContains(
+    key: String,
+    value: String,
+  ): Boolean {
+    val safeInstance = getInstance(context)
+    val set = safeInstance.getStringSet(key, setOf()) ?: setOf()
+    return set.contains(value)
+  }
+
+  fun hasValue(key: String): Boolean {
+    val safeInstance = getInstance(context)
+    return safeInstance.contains(key)
+  }
+
+  fun getValues(keys: Set<String>): Map<String, Any?> {
+    val safeInstance = getInstance(context)
+    return keys.associateWith { key ->
+      when (val value = safeInstance.all[key]) {
+        is String -> value
+        is Float -> value
+        is Boolean -> value
+        is Set<*> -> value
+        else -> null
+      }
+    }
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json
index 730bc6114..1111f8a0b 100644
--- a/modules/expo-bluesky-swiss-army/expo-module.config.json
+++ b/modules/expo-bluesky-swiss-army/expo-module.config.json
@@ -1,11 +1,11 @@
 {
   "platforms": ["ios", "tvos", "android", "web"],
   "ios": {
-    "modules": ["ExpoBlueskyDevicePrefsModule", "ExpoBlueskyReferrerModule"]
+    "modules": ["ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule"]
   },
   "android": {
     "modules": [
-      "expo.modules.blueskyswissarmy.deviceprefs.ExpoBlueskyDevicePrefsModule",
+      "expo.modules.blueskyswissarmy.sharedprefs.ExpoBlueskySharedPrefsModule",
       "expo.modules.blueskyswissarmy.referrer.ExpoBlueskyReferrerModule"
     ]
   }
diff --git a/modules/expo-bluesky-swiss-army/index.ts b/modules/expo-bluesky-swiss-army/index.ts
index 1b2f89249..89cea00a2 100644
--- a/modules/expo-bluesky-swiss-army/index.ts
+++ b/modules/expo-bluesky-swiss-army/index.ts
@@ -1,4 +1,4 @@
-import * as DevicePrefs from './src/DevicePrefs'
 import * as Referrer from './src/Referrer'
+import * as SharedPrefs from './src/SharedPrefs'
 
-export {DevicePrefs, Referrer}
+export {Referrer, SharedPrefs}
diff --git a/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift b/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift
deleted file mode 100644
index b13a9fe3f..000000000
--- a/modules/expo-bluesky-swiss-army/ios/DevicePrefs/ExpoBlueskyDevicePrefsModule.swift
+++ /dev/null
@@ -1,23 +0,0 @@
-import ExpoModulesCore
-
-public class ExpoBlueskyDevicePrefsModule: Module {
-  func getDefaults(_ useAppGroup: Bool) -> UserDefaults? {
-    if useAppGroup {
-      return UserDefaults(suiteName: "group.app.bsky")
-    } else {
-      return UserDefaults.standard
-    }
-  }
-
-  public func definition() -> ModuleDefinition {
-    Name("ExpoBlueskyDevicePrefs")
-
-    AsyncFunction("getStringValueAsync") { (key: String, useAppGroup: Bool) in
-      return self.getDefaults(useAppGroup)?.string(forKey: key)
-    }
-
-    AsyncFunction("setStringValueAsync") { (key: String, value: String?, useAppGroup: Bool) in
-      self.getDefaults(useAppGroup)?.setValue(value, forKey: key)
-    }
-  }
-}
diff --git a/modules/expo-bluesky-swiss-army/ios/SharedPrefs/ExpoBlueskySharedPrefsModule.swift b/modules/expo-bluesky-swiss-army/ios/SharedPrefs/ExpoBlueskySharedPrefsModule.swift
new file mode 100644
index 000000000..8549e5b48
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/SharedPrefs/ExpoBlueskySharedPrefsModule.swift
@@ -0,0 +1,62 @@
+import Foundation
+import ExpoModulesCore
+
+public class ExpoBlueskySharedPrefsModule: Module {
+  let defaults = UserDefaults(suiteName: "group.app.bsky")
+
+  func getDefaults(_ info: String = "(no info)") -> UserDefaults? {
+    guard let defaults = self.defaults else {
+      NSLog("Failed to get defaults for app group: \(info)")
+      return nil
+    }
+    return defaults
+  }
+
+  public func definition() -> ModuleDefinition {
+    Name("ExpoBlueskySharedPrefs")
+
+    // JavaScripValue causes a crash when trying to check `isString()`. Let's
+    // explicitly define setString instead.
+    Function("setString") { (key: String, value: String?) in
+      SharedPrefs.shared.setValue(key, value)
+    }
+
+    Function("setValue") { (key: String, value: JavaScriptValue) in
+      if value.isNumber() {
+        SharedPrefs.shared.setValue(key, value.getDouble())
+      } else if value.isBool() {
+        SharedPrefs.shared.setValue(key, value.getBool())
+      } else if value.isNull() || value.isUndefined() {
+        SharedPrefs.shared.removeValue(key)
+      }
+    }
+
+    Function("removeValue") { (key: String) in
+      SharedPrefs.shared.removeValue(key)
+    }
+
+    Function("getString") { (key: String) in
+      return SharedPrefs.shared.getString(key)
+    }
+
+    Function("getBool") { (key: String) in
+      return SharedPrefs.shared.getBool(key)
+    }
+
+    Function("getNumber") { (key: String) in
+      return SharedPrefs.shared.getNumber(key)
+    }
+
+    Function("addToSet") { (key: String, value: String) in
+      SharedPrefs.shared.addToSet(key, value)
+    }
+
+    Function("removeFromSet") { (key: String, value: String) in
+      SharedPrefs.shared.removeFromSet(key, value)
+    }
+
+    Function("setContains") { (key: String, value: String) in
+      return SharedPrefs.shared.setContains(key, value)
+    }
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/ios/SharedPrefs/SharedPrefs.swift b/modules/expo-bluesky-swiss-army/ios/SharedPrefs/SharedPrefs.swift
new file mode 100644
index 000000000..a11a71834
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/ios/SharedPrefs/SharedPrefs.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+public class SharedPrefs {
+  public static let shared = SharedPrefs()
+
+  private let defaults = UserDefaults(suiteName: "group.app.bsky")
+
+  init() {
+    if defaults == nil {
+      NSLog("Failed to get user defaults for app group.")
+    }
+  }
+
+  private func getDefaults(_ info: String = "(no info)") -> UserDefaults? {
+    guard let defaults = self.defaults else {
+      NSLog("Failed to get defaults for app group: \(info)")
+      return nil
+    }
+    return defaults
+  }
+
+  public func setValue(_ key: String, _ value: String?) {
+    getDefaults(key)?.setValue(value, forKey: key)
+  }
+
+  public func setValue(_ key: String, _ value: Double?) {
+    getDefaults(key)?.setValue(value, forKey: key)
+  }
+
+  public func setValue(_ key: String, _ value: Bool?) {
+    getDefaults(key)?.setValue(value, forKey: key)
+  }
+
+  public func _setAnyValue(_ key: String, _ value: Any?) {
+    getDefaults(key)?.setValue(value, forKey: key)
+  }
+
+  public func removeValue(_ key: String) {
+    getDefaults(key)?.removeObject(forKey: key)
+  }
+
+  public func getString(_ key: String) -> String? {
+    return getDefaults(key)?.string(forKey: key)
+  }
+
+  public func getNumber(_ key: String) -> Double? {
+    return getDefaults(key)?.double(forKey: key)
+  }
+
+  public func getBool(_ key: String) -> Bool? {
+    return getDefaults(key)?.bool(forKey: key)
+  }
+
+  public func addToSet(_ key: String, _ value: String) {
+    var dict: [String: Bool]?
+    if var currDict = getDefaults(key)?.dictionary(forKey: key) as? [String: Bool] {
+      currDict[value] = true
+      dict = currDict
+    } else {
+      dict = [
+        value: true
+      ]
+    }
+    getDefaults(key)?.setValue(dict, forKey: key)
+  }
+
+  public func removeFromSet(_ key: String, _ value: String) {
+    guard var dict = getDefaults(key)?.dictionary(forKey: key) as? [String: Bool] else {
+      return
+    }
+    dict.removeValue(forKey: value)
+    getDefaults(key)?.setValue(dict, forKey: key)
+  }
+
+  public func setContains(_ key: String, _ value: String) -> Bool {
+    guard let dict = getDefaults(key)?.dictionary(forKey: key) as? [String: Bool] else {
+      return false
+    }
+    return dict[value] == true
+  }
+
+  public func hasValue(_ key: String) -> Bool {
+    return getDefaults(key)?.value(forKey: key) != nil
+  }
+
+  public func getValues(_ keys: [String]) -> [String: Any?]? {
+    return getDefaults("keys:\(keys)")?.dictionaryWithValues(forKeys: keys)
+  }
+}
diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts
deleted file mode 100644
index 427185086..000000000
--- a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ios.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import {requireNativeModule} from 'expo-modules-core'
-
-const NativeModule = requireNativeModule('ExpoBlueskyDevicePrefs')
-
-export function getStringValueAsync(
-  key: string,
-  useAppGroup?: boolean,
-): Promise<string | null> {
-  return NativeModule.getStringValueAsync(key, useAppGroup)
-}
-
-export function setStringValueAsync(
-  key: string,
-  value: string | null,
-  useAppGroup?: boolean,
-): Promise<void> {
-  return NativeModule.setStringValueAsync(key, value, useAppGroup)
-}
diff --git a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts b/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts
deleted file mode 100644
index f1eee6c28..000000000
--- a/modules/expo-bluesky-swiss-army/src/DevicePrefs/index.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import {NotImplementedError} from '../NotImplemented'
-
-export function getStringValueAsync(
-  key: string,
-  useAppGroup?: boolean,
-): Promise<string | null> {
-  throw new NotImplementedError({key, useAppGroup})
-}
-
-export function setStringValueAsync(
-  key: string,
-  value: string | null,
-  useAppGroup?: boolean,
-): Promise<string | null> {
-  throw new NotImplementedError({key, value, useAppGroup})
-}
diff --git a/modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts b/modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts
new file mode 100644
index 000000000..0cea7c53b
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/SharedPrefs/index.native.ts
@@ -0,0 +1,51 @@
+import {requireNativeModule} from 'expo-modules-core'
+
+const NativeModule = requireNativeModule('ExpoBlueskySharedPrefs')
+
+export function setValue(
+  key: string,
+  value: string | number | boolean | null | undefined,
+): void {
+  // A bug on Android causes `JavaScripValue.isString()` to cause a crash on some occasions, seemingly because of a
+  // memory violation. Instead, we will use a specific function to set strings on this platform.
+  if (typeof value === 'string') {
+    return NativeModule.setString(key, value)
+  }
+  return NativeModule.setValue(key, value)
+}
+
+export function removeValue(key: string): void {
+  return NativeModule.removeValue(key)
+}
+
+export function getString(key: string): string | undefined {
+  return nullToUndefined(NativeModule.getString(key))
+}
+
+export function getNumber(key: string): number | undefined {
+  return nullToUndefined(NativeModule.getNumber(key))
+}
+
+export function getBool(key: string): boolean | undefined {
+  return nullToUndefined(NativeModule.getBool(key))
+}
+
+export function addToSet(key: string, value: string): void {
+  return NativeModule.addToSet(key, value)
+}
+
+export function removeFromSet(key: string, value: string): void {
+  return NativeModule.removeFromSet(key, value)
+}
+
+export function setContains(key: string, value: string): boolean {
+  return NativeModule.setContains(key, value)
+}
+
+// iOS returns `null` if a value does not exist, and Android returns `undefined. Normalize these here for JS types
+function nullToUndefined(value: any) {
+  if (value == null) {
+    return undefined
+  }
+  return value
+}
diff --git a/modules/expo-bluesky-swiss-army/src/SharedPrefs/index.ts b/modules/expo-bluesky-swiss-army/src/SharedPrefs/index.ts
new file mode 100644
index 000000000..769344007
--- /dev/null
+++ b/modules/expo-bluesky-swiss-army/src/SharedPrefs/index.ts
@@ -0,0 +1,36 @@
+import {NotImplementedError} from '../NotImplemented'
+
+export function setValue(
+  key: string,
+  value: string | number | boolean | null | undefined,
+): void {
+  throw new NotImplementedError({key, value})
+}
+
+export function removeValue(key: string): void {
+  throw new NotImplementedError({key})
+}
+
+export function getString(key: string): string | null {
+  throw new NotImplementedError({key})
+}
+
+export function getNumber(key: string): number | null {
+  throw new NotImplementedError({key})
+}
+
+export function getBool(key: string): boolean | null {
+  throw new NotImplementedError({key})
+}
+
+export function addToSet(key: string, value: string): void {
+  throw new NotImplementedError({key, value})
+}
+
+export function removeFromSet(key: string, value: string): void {
+  throw new NotImplementedError({key, value})
+}
+
+export function setContains(key: string, value: string): boolean {
+  throw new NotImplementedError({key, value})
+}
diff --git a/package.json b/package.json
index 141646d10..b56dd9e39 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
     "typecheck": "tsc --project ./tsconfig.check.json",
     "e2e:mock-server": "./jest/dev-infra/with-test-redis-and-db.sh ts-node --project tsconfig.e2e.json __e2e__/mock-server.ts",
     "e2e:metro": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:ios",
+    "e2e:metro-android": "EXPO_PUBLIC_ENV=e2e NODE_ENV=test RN_SRC_EXT=e2e.ts,e2e.tsx expo run:android",
     "e2e:run": "maestro test __e2e__",
     "perf:test": "NODE_ENV=test maestro test",
     "perf:test:run": "NODE_ENV=test maestro test __e2e__/perf-test.yml",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 4ecf3fff8..495435122 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -39,6 +39,7 @@ import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts'
 import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed'
 import {PreferencesThreads} from 'view/screens/PreferencesThreads'
 import {SavedFeeds} from 'view/screens/SavedFeeds'
+import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen'
 import HashtagScreen from '#/screens/Hashtag'
 import {ModerationScreen} from '#/screens/Moderation'
 import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers'
@@ -234,6 +235,11 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         options={{title: title(msg`Moderation states`), requireAuth: true}}
       />
       <Stack.Screen
+        name="SharedPreferencesTester"
+        getComponent={() => SharedPreferencesTesterScreen}
+        options={{title: title(msg`Shared Preferences Tester`)}}
+      />
+      <Stack.Screen
         name="Log"
         getComponent={() => LogScreen}
         options={{title: title(msg`Log`), requireAuth: true}}
diff --git a/src/components/hooks/useStarterPackEntry.native.ts b/src/components/hooks/useStarterPackEntry.native.ts
index b6e4ab05b..212ecae71 100644
--- a/src/components/hooks/useStarterPackEntry.native.ts
+++ b/src/components/hooks/useStarterPackEntry.native.ts
@@ -7,7 +7,7 @@ import {
 import {isAndroid} from 'platform/detection'
 import {useHasCheckedForStarterPack} from 'state/preferences/used-starter-packs'
 import {useSetActiveStarterPack} from 'state/shell/starter-pack'
-import {DevicePrefs, Referrer} from '../../../modules/expo-bluesky-swiss-army'
+import {Referrer, SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
 
 export function useStarterPackEntry() {
   const [ready, setReady] = React.useState(false)
@@ -39,14 +39,10 @@ export function useStarterPackEntry() {
           uri = createStarterPackLinkFromAndroidReferrer(res.installReferrer)
         }
       } else {
-        const res = await DevicePrefs.getStringValueAsync(
-          'starterPackUri',
-          true,
-        )
-
-        if (res) {
-          uri = httpStarterPackUriToAtUri(res)
-          DevicePrefs.setStringValueAsync('starterPackUri', null, true)
+        const starterPackUri = SharedPrefs.getString('starterPackUri')
+        if (starterPackUri) {
+          uri = httpStarterPackUriToAtUri(starterPackUri)
+          SharedPrefs.setValue('starterPackUri', null)
         }
       }
 
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 9d102f248..bda93fb40 100644
--- a/src/lib/routes/types.ts
+++ b/src/lib/routes/types.ts
@@ -25,6 +25,7 @@ export type CommonNavigatorParams = {
   ProfileLabelerLikedBy: {name: string}
   Debug: undefined
   DebugMod: undefined
+  SharedPreferencesTester: undefined
   Log: undefined
   Support: undefined
   PrivacyPolicy: undefined
diff --git a/src/screens/E2E/SharedPreferencesTesterScreen.tsx b/src/screens/E2E/SharedPreferencesTesterScreen.tsx
new file mode 100644
index 000000000..380f1080b
--- /dev/null
+++ b/src/screens/E2E/SharedPreferencesTesterScreen.tsx
@@ -0,0 +1,113 @@
+import React from 'react'
+import {View} from 'react-native'
+
+import {ScrollView} from 'view/com/util/Views'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {Text} from '#/components/Typography'
+import {SharedPrefs} from '../../../modules/expo-bluesky-swiss-army'
+
+export function SharedPreferencesTesterScreen() {
+  const [currentTestOutput, setCurrentTestOutput] = React.useState<string>('')
+
+  return (
+    <ScrollView contentContainerStyle={{backgroundColor: 'red'}}>
+      <View style={[a.flex_1]}>
+        <View>
+          <Text testID="testOutput">{currentTestOutput}</Text>
+        </View>
+        <View style={[a.flex_wrap]}>
+          <Button
+            label="btn"
+            testID="setStringBtn"
+            style={[a.self_center]}
+            variant="solid"
+            color="primary"
+            size="xsmall"
+            onPress={async () => {
+              SharedPrefs.removeValue('testerString')
+              SharedPrefs.setValue('testerString', 'Hello')
+              const str = SharedPrefs.getString('testerString')
+              console.log(JSON.stringify(str))
+              setCurrentTestOutput(`${str}`)
+            }}>
+            <ButtonText>Set String</ButtonText>
+          </Button>
+          <Button
+            label="btn"
+            testID="removeStringBtn"
+            style={[a.self_center]}
+            variant="solid"
+            color="primary"
+            size="xsmall"
+            onPress={async () => {
+              SharedPrefs.removeValue('testerString')
+              const str = SharedPrefs.getString('testerString')
+              setCurrentTestOutput(`${str}`)
+            }}>
+            <ButtonText>Remove String</ButtonText>
+          </Button>
+          <Button
+            label="btn"
+            testID="setBoolBtn"
+            style={[a.self_center]}
+            variant="solid"
+            color="primary"
+            size="xsmall"
+            onPress={async () => {
+              SharedPrefs.removeValue('testerBool')
+              SharedPrefs.setValue('testerBool', true)
+              const bool = SharedPrefs.getBool('testerBool')
+              setCurrentTestOutput(`${bool}`)
+            }}>
+            <ButtonText>Set Bool</ButtonText>
+          </Button>
+          <Button
+            label="btn"
+            testID="setNumberBtn"
+            style={[a.self_center]}
+            variant="solid"
+            color="primary"
+            size="xsmall"
+            onPress={async () => {
+              SharedPrefs.removeValue('testerNumber')
+              SharedPrefs.setValue('testerNumber', 123)
+              const num = SharedPrefs.getNumber('testerNumber')
+              setCurrentTestOutput(`${num}`)
+            }}>
+            <ButtonText>Set Number</ButtonText>
+          </Button>
+          <Button
+            label="btn"
+            testID="addToSetBtn"
+            style={[a.self_center]}
+            variant="solid"
+            color="primary"
+            size="xsmall"
+            onPress={async () => {
+              SharedPrefs.removeFromSet('testerSet', 'Hello!')
+              SharedPrefs.addToSet('testerSet', 'Hello!')
+              const contains = SharedPrefs.setContains('testerSet', 'Hello!')
+              setCurrentTestOutput(`${contains}`)
+            }}>
+            <ButtonText>Add to Set</ButtonText>
+          </Button>
+          <Button
+            label="btn"
+            testID="removeFromSetBtn"
+            style={[a.self_center]}
+            variant="solid"
+            color="primary"
+            size="xsmall"
+            onPress={async () => {
+              SharedPrefs.removeFromSet('testerSet', 'Hello!')
+              const contains = SharedPrefs.setContains('testerSet', 'Hello!')
+              setCurrentTestOutput(`${contains}`)
+            }}>
+            <ButtonText>Remove from Set</ButtonText>
+          </Button>
+        </View>
+      </View>
+    </ScrollView>
+  )
+}
diff --git a/src/view/screens/Storybook/Dialogs.tsx b/src/view/screens/Storybook/Dialogs.tsx
index 6d166d4b6..ca2420fed 100644
--- a/src/view/screens/Storybook/Dialogs.tsx
+++ b/src/view/screens/Storybook/Dialogs.tsx
@@ -1,7 +1,9 @@
 import React from 'react'
 import {View} from 'react-native'
+import {useNavigation} from '@react-navigation/native'
 
 import {useDialogStateControlContext} from '#/state/dialogs'
+import {NavigationProp} from 'lib/routes/types'
 import {atoms as a} from '#/alf'
 import {Button, ButtonText} from '#/components/Button'
 import * as Dialog from '#/components/Dialog'
@@ -18,6 +20,7 @@ export function Dialogs() {
   const [shouldRenderUnmountTest, setShouldRenderUnmountTest] =
     React.useState(false)
   const unmountTestInterval = React.useRef<number>()
+  const navigation = useNavigation<NavigationProp>()
 
   const onUnmountTestStartPressWithClose = () => {
     setShouldRenderUnmountTest(true)
@@ -134,6 +137,16 @@ export function Dialogs() {
         <ButtonText>End Unmount Test</ButtonText>
       </Button>
 
+      <Button
+        variant="solid"
+        color="primary"
+        size="small"
+        onPress={() => navigation.navigate('SharedPreferencesTester')}
+        label="two"
+        testID="sharedPrefsTestOpenBtn">
+        <ButtonText>Open Shared Prefs Tester</ButtonText>
+      </Button>
+
       <Prompt.Outer control={prompt}>
         <Prompt.TitleText>This is a prompt</Prompt.TitleText>
         <Prompt.DescriptionText>