diff options
author | Hailey <me@haileyok.com> | 2024-02-27 15:22:03 -0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-02-27 15:22:03 -0800 |
commit | d451f82f54974b7b3da1477a7e1f221628860f62 (patch) | |
tree | 631d60b9d9ef47129529068753b10029fb99c34f /modules | |
parent | ac726497a475f7492ee0269851979817b17d98c2 (diff) | |
download | voidsky-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
Diffstat (limited to 'modules')
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"] + } +} |