about summary refs log tree commit diff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-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
9 files changed, 446 insertions, 0 deletions
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"]
+  }
+}