about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-02-27 15:22:03 -0800
committerGitHub <noreply@github.com>2024-02-27 15:22:03 -0800
commitd451f82f54974b7b3da1477a7e1f221628860f62 (patch)
tree631d60b9d9ef47129529068753b10029fb99c34f
parentac726497a475f7492ee0269851979817b17d98c2 (diff)
downloadvoidsky-d451f82f54974b7b3da1477a7e1f221628860f62.tar.zst
Share Extension/Intents (#2587)
* add native ios code outside of ios project

* helper script

* going to be a lot of these commits to squash...backing up

* save

* start of an expo plugin

* create info.plist

* copy the view controller

* maybe working

* working

* wait working now

* working plugin

* use current scheme

* update intent path

* use better params

* support text in uri

* build

* use better encoding

* handle images

* cleanup ios plugin

* android

* move bash script to /scripts

* handle cases where loaded data is uiimage rather than uri

* remove unnecessary logic, allow more than 4 images and just take first 4

* android build plugin

* limit images to four on android

* use js for plugins, no need to build

* revert changes to app config

* use correct scheme on android

* android readme

* move ios extension to /modules

* remove unnecessary event

* revert typo

* plugin readme

* scripts readme

* add configurable scheme to .env, default to `bluesky`

* remove debug

* revert .gitignore change

* add comment about updating .env to app.config.js for those modifying scheme

* modify .env

* update android module to use the proper url

* update ios extension

* remove comment

* parse and validate incoming image uris

* fix types

* rm oops

* fix a few typos
-rw-r--r--app.config.js1
-rw-r--r--modules/Share-with-Bluesky/Info.plist41
-rw-r--r--modules/Share-with-Bluesky/Share-with-Bluesky.entitlements10
-rw-r--r--modules/Share-with-Bluesky/ShareViewController.swift153
-rw-r--r--modules/expo-receive-android-intents/README.md8
-rw-r--r--modules/expo-receive-android-intents/android/.gitignore15
-rw-r--r--modules/expo-receive-android-intents/android/build.gradle92
-rw-r--r--modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml2
-rw-r--r--modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt119
-rw-r--r--modules/expo-receive-android-intents/expo-module.config.json6
-rw-r--r--package.json3
-rw-r--r--plugins/shareExtension/README.md22
-rw-r--r--plugins/shareExtension/withAppEntitlements.js13
-rw-r--r--plugins/shareExtension/withExtensionEntitlements.js33
-rw-r--r--plugins/shareExtension/withExtensionInfoPlist.js39
-rw-r--r--plugins/shareExtension/withExtensionViewController.js31
-rw-r--r--plugins/shareExtension/withIntentFilters.js89
-rw-r--r--plugins/shareExtension/withShareExtensions.js47
-rw-r--r--plugins/shareExtension/withXcodeTarget.js55
-rw-r--r--scripts/README.md5
-rwxr-xr-xscripts/updateExtensions.sh10
-rw-r--r--src/lib/hooks/useIntentHandler.ts35
-rw-r--r--src/state/models/media/gallery.ts25
-rw-r--r--src/state/shell/composer.tsx2
-rw-r--r--src/view/com/composer/Composer.tsx11
-rw-r--r--src/view/shell/Composer.tsx2
-rw-r--r--src/view/shell/Composer.web.tsx3
27 files changed, 860 insertions, 12 deletions
diff --git a/app.config.js b/app.config.js
index 5bbe864a3..fa9735dc2 100644
--- a/app.config.js
+++ b/app.config.js
@@ -141,6 +141,7 @@ module.exports = function (config) {
           },
         ],
         './plugins/withAndroidManifestPlugin.js',
+        './plugins/shareExtension/withShareExtensions.js',
       ].filter(Boolean),
       extra: {
         eas: {
diff --git a/modules/Share-with-Bluesky/Info.plist b/modules/Share-with-Bluesky/Info.plist
new file mode 100644
index 000000000..90fe92345
--- /dev/null
+++ b/modules/Share-with-Bluesky/Info.plist
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>NSExtension</key>
+    <dict>
+      <key>NSExtensionPrincipalClass</key>
+      <string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
+      <key>NSExtensionAttributes</key>
+      <dict>
+        <key>NSExtensionActivationRule</key>
+        <dict>
+          <key>NSExtensionActivationSupportsText</key>
+          <true/>
+          <key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
+          <integer>1</integer>
+          <key>NSExtensionActivationSupportsImageWithMaxCount</key>
+          <integer>10</integer>
+        </dict>
+      </dict>
+      <key>NSExtensionPointIdentifier</key>
+      <string>com.apple.share-services</string>
+    </dict>
+    <key>MainAppScheme</key>
+    <string>bluesky</string>
+    <key>CFBundleName</key>
+    <string>$(PRODUCT_NAME)</string>
+    <key>CFBundleDisplayName</key>
+    <string>Extension</string>
+    <key>CFBundleIdentifier</key>
+    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+    <key>CFBundleVersion</key>
+    <string>$(CURRENT_PROJECT_VERSION)</string>
+    <key>CFBundleExecutable</key>
+    <string>$(EXECUTABLE_NAME)</string>
+    <key>CFBundlePackageType</key>
+    <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+    <key>CFBundleShortVersionString</key>
+    <string>$(MARKETING_VERSION)</string>
+  </dict>
+</plist>
diff --git a/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
new file mode 100644
index 000000000..22ca9157f
--- /dev/null
+++ b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>com.apple.security.application-groups</key>
+    <array>
+      <string>group.xyz.blueskyweb.app</string>
+    </array>
+  </dict>
+</plist>
diff --git a/modules/Share-with-Bluesky/ShareViewController.swift b/modules/Share-with-Bluesky/ShareViewController.swift
new file mode 100644
index 000000000..a16a290bf
--- /dev/null
+++ b/modules/Share-with-Bluesky/ShareViewController.swift
@@ -0,0 +1,153 @@
+import UIKit
+
+class ShareViewController: UIViewController {
+  // This allows other forks to use this extension while also changing their
+  // scheme.
+  let appScheme = Bundle.main.object(forInfoDictionaryKey: "MainAppScheme") as? String ?? "bluesky"
+
+  //
+  override func viewDidAppear(_ animated: Bool) {
+    super.viewDidAppear(animated)
+
+    guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem,
+          let attachments = extensionItem.attachments,
+          let firstAttachment = extensionItem.attachments?.first
+    else {
+      self.completeRequest()
+      return
+    }
+
+    Task {
+      if firstAttachment.hasItemConformingToTypeIdentifier("public.text") {
+        await self.handleText(item: firstAttachment)
+      } else if firstAttachment.hasItemConformingToTypeIdentifier("public.url") {
+        await self.handleUrl(item: firstAttachment)
+      } else if firstAttachment.hasItemConformingToTypeIdentifier("public.image") {
+        await self.handleImages(items: attachments)
+      } else {
+        self.completeRequest()
+      }
+    }
+  }
+
+  private func handleText(item: NSItemProvider) async -> Void {
+    do {
+      if let data = try await item.loadItem(forTypeIdentifier: "public.text") as? String {
+        if let encoded = data.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+           let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
+        {
+          _ = self.openURL(url)
+        }
+      }
+      self.completeRequest()
+    } catch {
+      self.completeRequest()
+    }
+  }
+
+  private func handleUrl(item: NSItemProvider) async -> Void {
+    do {
+      if let data = try await item.loadItem(forTypeIdentifier: "public.url") as? URL {
+        if let encoded = data.absoluteString.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+           let url = URL(string: "\(self.appScheme)://intent/compose?text=\(encoded)")
+        {
+          _ = self.openURL(url)
+        }
+      }
+      self.completeRequest()
+    } catch {
+      self.completeRequest()
+    }
+  }
+
+  private func handleImages(items: [NSItemProvider]) async -> Void {
+    let firstFourItems: [NSItemProvider]
+    if items.count < 4 {
+      firstFourItems = items
+    } else {
+      firstFourItems = Array(items[0...3])
+    }
+
+    var valid = true
+    var imageUris = ""
+
+    for (index, item) in firstFourItems.enumerated() {
+      var imageUriInfo: String? = nil
+
+      do {
+        if let dataUri = try await item.loadItem(forTypeIdentifier: "public.image") as? URL {
+          // We need to duplicate this image, since we don't have access to the outgoing temp directory
+          // We also will get the image dimensions here, sinze RN makes it difficult to get those dimensions for local files
+          let data = try Data(contentsOf: dataUri)
+          let image = UIImage(data: data)
+          imageUriInfo = self.saveImageWithInfo(image)
+        } else if let image = try await item.loadItem(forTypeIdentifier: "public.image") as? UIImage {
+          imageUriInfo = self.saveImageWithInfo(image)
+        }
+      } catch {
+        valid = false
+      }
+
+      if let imageUriInfo = imageUriInfo {
+        imageUris.append(imageUriInfo)
+        if index < items.count - 1 {
+          imageUris.append(",")
+        }
+      } else {
+        valid = false
+      }
+    }
+
+    if valid,
+       let encoded = imageUris.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed),
+       let url = URL(string: "\(self.appScheme)://intent/compose?imageUris=\(encoded)")
+    {
+      _ = self.openURL(url)
+    }
+
+    self.completeRequest()
+  }
+
+  private func saveImageWithInfo(_ image: UIImage?) -> String? {
+    guard let image = image else {
+      return nil
+    }
+
+    do {
+      // Saving this file to the bundle group's directory lets us access it from
+      // inside of the app. Otherwise, we wouldn't have access even though the
+      // extension does.
+      if let dir = FileManager()
+        .containerURL(
+          forSecurityApplicationGroupIdentifier: "group.\(Bundle.main.bundleIdentifier?.replacingOccurrences(of: ".Share-with-Bluesky", with: "") ?? "")")
+      {
+        let filePath = "\(dir.absoluteString)\(ProcessInfo.processInfo.globallyUniqueString).jpeg"
+
+        if let newUri = URL(string: filePath),
+           let jpegData = image.jpegData(compressionQuality: 1)
+        {
+          try jpegData.write(to: newUri)
+          return "\(newUri.absoluteString)|\(image.size.width)|\(image.size.height)"
+        }
+      }
+      return nil
+    } catch {
+      return nil
+    }
+  }
+
+  private func completeRequest() -> Void {
+    self.extensionContext?.completeRequest(returningItems: nil)
+  }
+
+  @objc func openURL(_ url: URL) -> Bool {
+    var responder: UIResponder? = self
+    while responder != nil {
+      if let application = responder as? UIApplication {
+          return application.perform(#selector(openURL(_:)), with: url) != nil
+      }
+      responder = responder?.next
+    }
+    return false
+  }
+}
diff --git a/modules/expo-receive-android-intents/README.md b/modules/expo-receive-android-intents/README.md
new file mode 100644
index 000000000..7e8506860
--- /dev/null
+++ b/modules/expo-receive-android-intents/README.md
@@ -0,0 +1,8 @@
+# Expo Receive Android Intents
+
+This module handles incoming intents on Android. Handled intents are `text/plain` and `image/*` (single or multiple).
+The module handles saving images to the app's filesystem for access within the app, limiting the selection of images
+to a max of four, and handling intent types. No JS code is required for this module, and it is no-op on non-android
+platforms.
+
+No installation is required. Gradle will automatically add this module on build.
diff --git a/modules/expo-receive-android-intents/android/.gitignore b/modules/expo-receive-android-intents/android/.gitignore
new file mode 100644
index 000000000..877b87e9a
--- /dev/null
+++ b/modules/expo-receive-android-intents/android/.gitignore
@@ -0,0 +1,15 @@
+# OSX
+#
+.DS_Store
+
+# Android/IntelliJ
+#
+build/
+.idea
+.gradle
+local.properties
+*.iml
+*.hprof
+
+# Bundle artifacts
+*.jsbundle
diff --git a/modules/expo-receive-android-intents/android/build.gradle b/modules/expo-receive-android-intents/android/build.gradle
new file mode 100644
index 000000000..3712dda40
--- /dev/null
+++ b/modules/expo-receive-android-intents/android/build.gradle
@@ -0,0 +1,92 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'maven-publish'
+
+group = 'xyz.blueskyweb.app.exporeceiveandroidintents'
+version = '0.4.1'
+
+buildscript {
+  def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+  if (expoModulesCorePlugin.exists()) {
+    apply from: expoModulesCorePlugin
+    applyKotlinExpoModulesCorePlugin()
+  }
+
+  // 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
+  }
+
+  // Ensures backward compatibility
+  ext.getKotlinVersion = {
+    if (ext.has("kotlinVersion")) {
+      ext.kotlinVersion()
+    } else {
+      ext.safeExtGet("kotlinVersion", "1.8.10")
+    }
+  }
+
+  repositories {
+    mavenCentral()
+  }
+
+  dependencies {
+    classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
+  }
+}
+
+afterEvaluate {
+  publishing {
+    publications {
+      release(MavenPublication) {
+        from components.release
+      }
+    }
+    repositories {
+      maven {
+        url = mavenLocal().url
+      }
+    }
+  }
+}
+
+android {
+  compileSdkVersion safeExtGet("compileSdkVersion", 33)
+
+  def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
+  if (agpVersion.tokenize('.')[0].toInteger() < 8) {
+    compileOptions {
+      sourceCompatibility JavaVersion.VERSION_11
+      targetCompatibility JavaVersion.VERSION_11
+    }
+
+    kotlinOptions {
+      jvmTarget = JavaVersion.VERSION_11.majorVersion
+    }
+  }
+
+  namespace "xyz.blueskyweb.app.exporeceiveandroidintents"
+  defaultConfig {
+    minSdkVersion safeExtGet("minSdkVersion", 21)
+    targetSdkVersion safeExtGet("targetSdkVersion", 34)
+    versionCode 1
+    versionName "0.4.1"
+  }
+  lintOptions {
+    abortOnError false
+  }
+  publishing {
+    singleVariant("release") {
+      withSourcesJar()
+    }
+  }
+}
+
+repositories {
+  mavenCentral()
+}
+
+dependencies {
+  implementation project(':expo-modules-core')
+  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
+}
diff --git a/modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml b/modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..bdae66c8f
--- /dev/null
+++ b/modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<manifest>
+</manifest>
diff --git a/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt
new file mode 100644
index 000000000..c2e17fb80
--- /dev/null
+++ b/modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt
@@ -0,0 +1,119 @@
+package xyz.blueskyweb.app.exporeceiveandroidintents
+
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import androidx.core.net.toUri
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+import java.io.File
+import java.io.FileOutputStream
+import java.net.URLEncoder
+
+class ExpoReceiveAndroidIntentsModule : Module() {
+  override fun definition() = ModuleDefinition {
+    Name("ExpoReceiveAndroidIntents")
+
+    OnNewIntent {
+      handleIntent(it)
+    }
+  }
+
+  private fun handleIntent(intent: Intent?) {
+    if(appContext.currentActivity == null || intent == null) return
+
+    if (intent.action == Intent.ACTION_SEND) {
+      if (intent.type == "text/plain") {
+        handleTextIntent(intent)
+      } else if (intent.type.toString().startsWith("image/")) {
+        handleImageIntent(intent)
+      }
+    } else if (intent.action == Intent.ACTION_SEND_MULTIPLE) {
+      if (intent.type.toString().startsWith("image/")) {
+        handleImagesIntent(intent)
+      }
+    }
+  }
+
+  private fun handleTextIntent(intent: Intent) {
+    intent.getStringExtra(Intent.EXTRA_TEXT)?.let {
+      val encoded = URLEncoder.encode(it, "UTF-8")
+      "bluesky://intent/compose?text=${encoded}".toUri().let { uri ->
+        val newIntent = Intent(Intent.ACTION_VIEW, uri)
+        appContext.currentActivity?.startActivity(newIntent)
+      }
+    }
+  }
+
+  private fun handleImageIntent(intent: Intent) {
+    val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+      intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
+    } else {
+      intent.getParcelableExtra(Intent.EXTRA_STREAM)
+    }
+    if (uri == null) return
+
+    handleImageIntents(listOf(uri))
+  }
+
+  private fun handleImagesIntent(intent: Intent) {
+    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+      intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)?.let {
+        handleImageIntents(it.filterIsInstance<Uri>().take(4))
+      }
+    } else {
+      intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.let {
+        handleImageIntents(it.filterIsInstance<Uri>().take(4))
+      }
+    }
+  }
+
+  private fun handleImageIntents(uris: List<Uri>) {
+    var allParams = ""
+
+    uris.forEachIndexed { index, uri ->
+      val info = getImageInfo(uri)
+      val params = buildUriData(info)
+      allParams = "${allParams}${params}"
+
+      if (index < uris.count() - 1) {
+        allParams = "${allParams},"
+      }
+    }
+
+    val encoded = URLEncoder.encode(allParams, "UTF-8")
+
+    "bluesky://intent/compose?imageUris=${encoded}".toUri().let {
+      val newIntent = Intent(Intent.ACTION_VIEW, it)
+      appContext.currentActivity?.startActivity(newIntent)
+    }
+  }
+
+  private fun getImageInfo(uri: Uri): Map<String, Any> {
+    val bitmap = MediaStore.Images.Media.getBitmap(appContext.currentActivity?.contentResolver, uri)
+    // We have to save this so that we can access it later when uploading the image.
+    // createTempFile will automatically place a unique string between "img" and "temp.jpeg"
+    val file = File.createTempFile("img", "temp.jpeg", appContext.currentActivity?.cacheDir)
+    val out = FileOutputStream(file)
+    bitmap.compress(Bitmap.CompressFormat.JPEG, 100, out)
+    out.flush()
+    out.close()
+
+    return mapOf(
+      "width" to bitmap.width,
+      "height" to bitmap.height,
+      "path" to file.path.toString()
+    )
+  }
+
+  // We will pas the width and height to the app here, since getting measurements
+  // on the RN side is a bit more involved, and we already have them here anyway.
+  private fun buildUriData(info: Map<String, Any>): String {
+    val path = info.getValue("path")
+    val width = info.getValue("width")
+    val height = info.getValue("height")
+    return "file://${path}|${width}|${height}"
+  }
+}
diff --git a/modules/expo-receive-android-intents/expo-module.config.json b/modules/expo-receive-android-intents/expo-module.config.json
new file mode 100644
index 000000000..8f01fb6c9
--- /dev/null
+++ b/modules/expo-receive-android-intents/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+  "platforms": ["android"],
+  "android": {
+    "modules": ["xyz.blueskyweb.app.exporeceiveandroidintents.ExpoReceiveAndroidIntentsModule"]
+  }
+}
diff --git a/package.json b/package.json
index 2d520b4be..e9dd9202d 100644
--- a/package.json
+++ b/package.json
@@ -40,7 +40,8 @@
     "intl:check": "yarn intl:extract && git diff-index -G'(^[^\\*# /])|(^#\\w)|(^\\s+[^\\*#/])' HEAD || (echo '\n⚠️ i18n detected un-extracted translations\n' && exit 1)",
     "intl:extract": "lingui extract",
     "intl:compile": "lingui compile",
-    "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android"
+    "nuke": "rm -rf ./node_modules && rm -rf ./ios && rm -rf ./android",
+    "update-extensions": "scripts/updateExtensions.sh"
   },
   "dependencies": {
     "@atproto/api": "^0.10.0",
diff --git a/plugins/shareExtension/README.md b/plugins/shareExtension/README.md
new file mode 100644
index 000000000..2b57e624a
--- /dev/null
+++ b/plugins/shareExtension/README.md
@@ -0,0 +1,22 @@
+# Share extension plugin for Expo
+
+This plugin handles moving the necessary files into their respective iOS and Android targets and updating the build
+phases, plists, manifests, etc.
+
+## Steps
+
+### ios
+
+1. Update entitlements
+2. Set the app group to group.<identifier>
+3. Add the extension plist
+4. Add the view controller
+5. Update the xcode project's build phases
+
+### android
+
+1. Update the manifest with the intents the app can receive
+
+## Credits
+
+Adapted from https://github.com/andrew-levy/react-native-safari-extension and https://github.com/timedtext/expo-config-plugin-ios-share-extension/blob/master/src/withShareExtensionXcodeTarget.ts
diff --git a/plugins/shareExtension/withAppEntitlements.js b/plugins/shareExtension/withAppEntitlements.js
new file mode 100644
index 000000000..6f9136c37
--- /dev/null
+++ b/plugins/shareExtension/withAppEntitlements.js
@@ -0,0 +1,13 @@
+const {withEntitlementsPlist} = require('@expo/config-plugins')
+
+const withAppEntitlements = config => {
+  // eslint-disable-next-line no-shadow
+  return withEntitlementsPlist(config, async config => {
+    config.modResults['com.apple.security.application-groups'] = [
+      `group.${config.ios.bundleIdentifier}`,
+    ]
+    return config
+  })
+}
+
+module.exports = {withAppEntitlements}
diff --git a/plugins/shareExtension/withExtensionEntitlements.js b/plugins/shareExtension/withExtensionEntitlements.js
new file mode 100644
index 000000000..e6bbf9d23
--- /dev/null
+++ b/plugins/shareExtension/withExtensionEntitlements.js
@@ -0,0 +1,33 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionEntitlements = (config, {extensionName}) => {
+  // eslint-disable-next-line no-shadow
+  return withInfoPlist(config, config => {
+    const extensionEntitlementsPath = path.join(
+      config.modRequest.platformProjectRoot,
+      extensionName,
+      `${extensionName}.entitlements`,
+    )
+
+    const shareExtensionEntitlements = {
+      'com.apple.security.application-groups': [
+        `group.${config.ios?.bundleIdentifier}`,
+      ],
+    }
+
+    fs.mkdirSync(path.dirname(extensionEntitlementsPath), {
+      recursive: true,
+    })
+    fs.writeFileSync(
+      extensionEntitlementsPath,
+      plist.default.build(shareExtensionEntitlements),
+    )
+
+    return config
+  })
+}
+
+module.exports = {withExtensionEntitlements}
diff --git a/plugins/shareExtension/withExtensionInfoPlist.js b/plugins/shareExtension/withExtensionInfoPlist.js
new file mode 100644
index 000000000..9afc4d5f9
--- /dev/null
+++ b/plugins/shareExtension/withExtensionInfoPlist.js
@@ -0,0 +1,39 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionInfoPlist = (config, {extensionName}) => {
+  // eslint-disable-next-line no-shadow
+  return withInfoPlist(config, config => {
+    const plistPath = path.join(
+      config.modRequest.projectRoot,
+      'modules',
+      extensionName,
+      'Info.plist',
+    )
+    const targetPath = path.join(
+      config.modRequest.platformProjectRoot,
+      extensionName,
+      'Info.plist',
+    )
+
+    const extPlist = plist.default.parse(fs.readFileSync(plistPath).toString())
+
+    extPlist.MainAppScheme = config.scheme
+    extPlist.CFBundleName = '$(PRODUCT_NAME)'
+    extPlist.CFBundleDisplayName = 'Extension'
+    extPlist.CFBundleIdentifier = '$(PRODUCT_BUNDLE_IDENTIFIER)'
+    extPlist.CFBundleVersion = '$(CURRENT_PROJECT_VERSION)'
+    extPlist.CFBundleExecutable = '$(EXECUTABLE_NAME)'
+    extPlist.CFBundlePackageType = '$(PRODUCT_BUNDLE_PACKAGE_TYPE)'
+    extPlist.CFBundleShortVersionString = '$(MARKETING_VERSION)'
+
+    fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+    fs.writeFileSync(targetPath, plist.default.build(extPlist))
+
+    return config
+  })
+}
+
+module.exports = {withExtensionInfoPlist}
diff --git a/plugins/shareExtension/withExtensionViewController.js b/plugins/shareExtension/withExtensionViewController.js
new file mode 100644
index 000000000..cd29bea7d
--- /dev/null
+++ b/plugins/shareExtension/withExtensionViewController.js
@@ -0,0 +1,31 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionViewController = (
+  config,
+  {controllerName, extensionName},
+) => {
+  // eslint-disable-next-line no-shadow
+  return withXcodeProject(config, config => {
+    const controllerPath = path.join(
+      config.modRequest.projectRoot,
+      'modules',
+      extensionName,
+      `${controllerName}.swift`,
+    )
+
+    const targetPath = path.join(
+      config.modRequest.platformProjectRoot,
+      extensionName,
+      `${controllerName}.swift`,
+    )
+
+    fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+    fs.copyFileSync(controllerPath, targetPath)
+
+    return config
+  })
+}
+
+module.exports = {withExtensionViewController}
diff --git a/plugins/shareExtension/withIntentFilters.js b/plugins/shareExtension/withIntentFilters.js
new file mode 100644
index 000000000..605fcfd05
--- /dev/null
+++ b/plugins/shareExtension/withIntentFilters.js
@@ -0,0 +1,89 @@
+const {withAndroidManifest} = require('@expo/config-plugins')
+
+const withIntentFilters = config => {
+  // eslint-disable-next-line no-shadow
+  return withAndroidManifest(config, config => {
+    const intents = [
+      {
+        action: [
+          {
+            $: {
+              'android:name': 'android.intent.action.SEND',
+            },
+          },
+        ],
+        category: [
+          {
+            $: {
+              'android:name': 'android.intent.category.DEFAULT',
+            },
+          },
+        ],
+        data: [
+          {
+            $: {
+              'android:mimeType': 'image/*',
+            },
+          },
+        ],
+      },
+      {
+        action: [
+          {
+            $: {
+              'android:name': 'android.intent.action.SEND',
+            },
+          },
+        ],
+        category: [
+          {
+            $: {
+              'android:name': 'android.intent.category.DEFAULT',
+            },
+          },
+        ],
+        data: [
+          {
+            $: {
+              'android:mimeType': 'text/plain',
+            },
+          },
+        ],
+      },
+      {
+        action: [
+          {
+            $: {
+              'android:name': 'android.intent.action.SEND_MULTIPLE',
+            },
+          },
+        ],
+        category: [
+          {
+            $: {
+              'android:name': 'android.intent.category.DEFAULT',
+            },
+          },
+        ],
+        data: [
+          {
+            $: {
+              'android:mimeType': 'image/*',
+            },
+          },
+        ],
+      },
+    ]
+
+    const intentFilter =
+      config.modResults.manifest.application?.[0].activity?.[0]['intent-filter']
+
+    if (intentFilter) {
+      intentFilter.push(...intents)
+    }
+
+    return config
+  })
+}
+
+module.exports = {withIntentFilters}
diff --git a/plugins/shareExtension/withShareExtensions.js b/plugins/shareExtension/withShareExtensions.js
new file mode 100644
index 000000000..55a26c75e
--- /dev/null
+++ b/plugins/shareExtension/withShareExtensions.js
@@ -0,0 +1,47 @@
+const {withPlugins} = require('@expo/config-plugins')
+const {withAppEntitlements} = require('./withAppEntitlements')
+const {withXcodeTarget} = require('./withXcodeTarget')
+const {withExtensionEntitlements} = require('./withExtensionEntitlements')
+const {withExtensionInfoPlist} = require('./withExtensionInfoPlist')
+const {withExtensionViewController} = require('./withExtensionViewController')
+const {withIntentFilters} = require('./withIntentFilters')
+
+const SHARE_EXTENSION_NAME = 'Share-with-Bluesky'
+const SHARE_EXTENSION_CONTROLLER_NAME = 'ShareViewController'
+
+const withShareExtensions = config => {
+  return withPlugins(config, [
+    // IOS
+    withAppEntitlements,
+    [
+      withExtensionEntitlements,
+      {
+        extensionName: SHARE_EXTENSION_NAME,
+      },
+    ],
+    [
+      withExtensionInfoPlist,
+      {
+        extensionName: SHARE_EXTENSION_NAME,
+      },
+    ],
+    [
+      withExtensionViewController,
+      {
+        extensionName: SHARE_EXTENSION_NAME,
+        controllerName: SHARE_EXTENSION_CONTROLLER_NAME,
+      },
+    ],
+    [
+      withXcodeTarget,
+      {
+        extensionName: SHARE_EXTENSION_NAME,
+        controllerName: SHARE_EXTENSION_CONTROLLER_NAME,
+      },
+    ],
+    // Android
+    withIntentFilters,
+  ])
+}
+
+module.exports = withShareExtensions
diff --git a/plugins/shareExtension/withXcodeTarget.js b/plugins/shareExtension/withXcodeTarget.js
new file mode 100644
index 000000000..4f43c0926
--- /dev/null
+++ b/plugins/shareExtension/withXcodeTarget.js
@@ -0,0 +1,55 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+
+const withXcodeTarget = (config, {extensionName, controllerName}) => {
+  // eslint-disable-next-line no-shadow
+  return withXcodeProject(config, config => {
+    const pbxProject = config.modResults
+
+    const target = pbxProject.addTarget(
+      extensionName,
+      'app_extension',
+      extensionName,
+    )
+    pbxProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', target.uuid)
+    pbxProject.addBuildPhase(
+      [],
+      'PBXResourcesBuildPhase',
+      'Resources',
+      target.uuid,
+    )
+    const pbxGroupKey = pbxProject.pbxCreateGroup(extensionName, extensionName)
+    pbxProject.addFile(`${extensionName}/Info.plist`, pbxGroupKey)
+    pbxProject.addSourceFile(
+      `${extensionName}/${controllerName}.swift`,
+      {target: target.uuid},
+      pbxGroupKey,
+    )
+
+    var configurations = pbxProject.pbxXCBuildConfigurationSection()
+    for (var key in configurations) {
+      if (typeof configurations[key].buildSettings !== 'undefined') {
+        var buildSettingsObj = configurations[key].buildSettings
+        if (
+          typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
+          buildSettingsObj.PRODUCT_NAME === `"${extensionName}"`
+        ) {
+          buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
+          buildSettingsObj.INFOPLIST_FILE = `"${extensionName}/Info.plist"`
+          buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${extensionName}.entitlements"`
+          buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
+          buildSettingsObj.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber}"`
+          buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
+          buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
+          buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.${extensionName}"`
+          buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
+          buildSettingsObj.SWIFT_VERSION = '5.0'
+          buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1,2"`
+        }
+      }
+    }
+
+    return config
+  })
+}
+
+module.exports = {withXcodeTarget}
diff --git a/scripts/README.md b/scripts/README.md
new file mode 100644
index 000000000..99d6236f9
--- /dev/null
+++ b/scripts/README.md
@@ -0,0 +1,5 @@
+# Tool Scripts
+
+## updateExtensions.sh
+
+Updates the extensions in `/modules` with the current iOS/Android project changes.
diff --git a/scripts/updateExtensions.sh b/scripts/updateExtensions.sh
new file mode 100755
index 000000000..f4e462b74
--- /dev/null
+++ b/scripts/updateExtensions.sh
@@ -0,0 +1,10 @@
+#!/bin/bash
+IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
+MODULES_DIRECTORY="./modules"
+
+if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
+  echo "$IOS_SHARE_EXTENSION_DIRECTORY not found inside of your iOS project."
+  exit 1
+else
+  cp -R $IOS_SHARE_EXTENSION_DIRECTORY $MODULES_DIRECTORY
+fi
diff --git a/src/lib/hooks/useIntentHandler.ts b/src/lib/hooks/useIntentHandler.ts
index 249e6898e..de9a96da9 100644
--- a/src/lib/hooks/useIntentHandler.ts
+++ b/src/lib/hooks/useIntentHandler.ts
@@ -6,6 +6,8 @@ import {useSession} from 'state/session'
 
 type IntentType = 'compose'
 
+const VALID_IMAGE_REGEX = /^[\w.:\-_/]+\|\d+(\.\d+)?\|\d+(\.\d+)?$/
+
 export function useIntentHandler() {
   const incomingUrl = Linking.useURL()
   const composeIntent = useComposeIntent()
@@ -29,7 +31,7 @@ export function useIntentHandler() {
         case 'compose': {
           composeIntent({
             text: params.get('text'),
-            imageUris: params.get('imageUris'),
+            imageUrisStr: params.get('imageUris'),
           })
         }
       }
@@ -45,18 +47,39 @@ function useComposeIntent() {
 
   return React.useCallback(
     ({
-      // eslint-disable-next-line @typescript-eslint/no-unused-vars
       text,
-      // eslint-disable-next-line @typescript-eslint/no-unused-vars
-      imageUris,
+      imageUrisStr,
     }: {
       text: string | null
-      imageUris: string | null // unused for right now, will be used later with intents
+      imageUrisStr: string | null // unused for right now, will be used later with intents
     }) => {
       if (!hasSession) return
 
+      const imageUris = imageUrisStr
+        ?.split(',')
+        .filter(part => {
+          // For some security, we're going to filter out any image uri that is external. We don't want someone to
+          // be able to provide some link like "bluesky://intent/compose?imageUris=https://IHaveYourIpNow.com/image.jpeg
+          // and we load that image
+          if (part.includes('https://') || part.includes('http://')) {
+            return false
+          }
+          // We also should just filter out cases that don't have all the info we need
+          if (!VALID_IMAGE_REGEX.test(part)) {
+            return false
+          }
+          return true
+        })
+        .map(part => {
+          const [uri, width, height] = part.split('|')
+          return {uri, width: Number(width), height: Number(height)}
+        })
+
       setTimeout(() => {
-        openComposer({}) // will pass in values to the composer here in the share extension
+        openComposer({
+          text: text ?? undefined,
+          imageUris: isNative ? imageUris : undefined,
+        })
       }, 500)
     },
     [openComposer, hasSession],
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index 04023bf82..9c8c13010 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -4,11 +4,21 @@ import {Image as RNImage} from 'react-native-image-crop-picker'
 import {openPicker} from 'lib/media/picker'
 import {getImageDim} from 'lib/media/manip'
 
+interface InitialImageUri {
+  uri: string
+  width: number
+  height: number
+}
+
 export class GalleryModel {
   images: ImageModel[] = []
 
-  constructor() {
+  constructor(uris?: {uri: string; width: number; height: number}[]) {
     makeAutoObservable(this)
+
+    if (uris) {
+      this.addFromUris(uris)
+    }
   }
 
   get isEmpty() {
@@ -23,7 +33,7 @@ export class GalleryModel {
     return this.images.some(image => image.altText.trim() === '')
   }
 
-  async add(image_: Omit<RNImage, 'size'>) {
+  *add(image_: Omit<RNImage, 'size'>) {
     if (this.size >= 4) {
       return
     }
@@ -86,4 +96,15 @@ export class GalleryModel {
       }),
     )
   }
+
+  async addFromUris(uris: InitialImageUri[]) {
+    for (const uriObj of uris) {
+      this.add({
+        mime: 'image/jpeg',
+        height: uriObj.height,
+        width: uriObj.width,
+        path: uriObj.uri,
+      })
+    }
+  }
 }
diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx
index 696a3c5ba..c9dbfbeac 100644
--- a/src/state/shell/composer.tsx
+++ b/src/state/shell/composer.tsx
@@ -38,6 +38,8 @@ export interface ComposerOpts {
   quote?: ComposerOptsQuote
   mention?: string // handle of user to mention
   openPicker?: (pos: DOMRect | undefined) => void
+  text?: string
+  imageUris?: {uri: string; width: number; height: number}[]
 }
 
 type StateContext = ComposerOpts | undefined
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 1ed6b98a5..2855d4232 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -71,6 +71,8 @@ export const ComposePost = observer(function ComposePost({
   quote: initQuote,
   mention: initMention,
   openPicker,
+  text: initText,
+  imageUris: initImageUris,
 }: Props) {
   const {currentAccount} = useSession()
   const {data: currentProfile} = useProfileQuery({did: currentAccount!.did})
@@ -91,7 +93,9 @@ export const ComposePost = observer(function ComposePost({
   const [error, setError] = useState('')
   const [richtext, setRichText] = useState(
     new RichText({
-      text: initMention
+      text: initText
+        ? initText
+        : initMention
         ? insertMentionAt(
             `@${initMention}`,
             initMention.length + 1,
@@ -110,7 +114,10 @@ export const ComposePost = observer(function ComposePost({
   const [labels, setLabels] = useState<string[]>([])
   const [threadgate, setThreadgate] = useState<ThreadgateSetting[]>([])
   const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set())
-  const gallery = useMemo(() => new GalleryModel(), [])
+  const gallery = useMemo(
+    () => new GalleryModel(initImageUris),
+    [initImageUris],
+  )
   const onClose = useCallback(() => {
     closeComposer()
   }, [closeComposer])
diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx
index d37ff4fb7..1937fcb6e 100644
--- a/src/view/shell/Composer.tsx
+++ b/src/view/shell/Composer.tsx
@@ -55,6 +55,8 @@ export const Composer = observer(function ComposerImpl({
         onPost={state.onPost}
         quote={state.quote}
         mention={state.mention}
+        text={state.text}
+        imageUris={state.imageUris}
       />
     </Animated.View>
   )
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index 99e659d62..00233f66a 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -9,7 +9,7 @@ import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 import {
   EmojiPicker,
   EmojiPickerState,
-} from 'view/com/composer/text-input/web/EmojiPicker.web.tsx'
+} from 'view/com/composer/text-input/web/EmojiPicker.web'
 
 const BOTTOM_BAR_HEIGHT = 61
 
@@ -69,6 +69,7 @@ export function Composer({}: {winHeight: number}) {
           onPost={state.onPost}
           mention={state.mention}
           openPicker={onOpenPicker}
+          text={state.text}
         />
       </Animated.View>
       <EmojiPicker state={pickerState} close={onClosePicker} />