From d451f82f54974b7b3da1477a7e1f221628860f62 Mon Sep 17 00:00:00 2001 From: Hailey Date: Tue, 27 Feb 2024 15:22:03 -0800 Subject: [PATCH] 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 --- app.config.js | 1 + modules/Share-with-Bluesky/Info.plist | 41 +++++ .../Share-with-Bluesky.entitlements | 10 ++ .../ShareViewController.swift | 153 ++++++++++++++++++ .../expo-receive-android-intents/README.md | 8 + .../android/.gitignore | 15 ++ .../android/build.gradle | 92 +++++++++++ .../android/src/main/AndroidManifest.xml | 2 + .../ExpoReceiveAndroidIntentsModule.kt | 119 ++++++++++++++ .../expo-module.config.json | 6 + package.json | 3 +- plugins/shareExtension/README.md | 22 +++ plugins/shareExtension/withAppEntitlements.js | 13 ++ .../withExtensionEntitlements.js | 33 ++++ .../shareExtension/withExtensionInfoPlist.js | 39 +++++ .../withExtensionViewController.js | 31 ++++ plugins/shareExtension/withIntentFilters.js | 89 ++++++++++ plugins/shareExtension/withShareExtensions.js | 47 ++++++ plugins/shareExtension/withXcodeTarget.js | 55 +++++++ scripts/README.md | 5 + scripts/updateExtensions.sh | 10 ++ src/lib/hooks/useIntentHandler.ts | 35 +++- src/state/models/media/gallery.ts | 25 ++- src/state/shell/composer.tsx | 2 + src/view/com/composer/Composer.tsx | 11 +- src/view/shell/Composer.tsx | 2 + src/view/shell/Composer.web.tsx | 3 +- 27 files changed, 860 insertions(+), 12 deletions(-) create mode 100644 modules/Share-with-Bluesky/Info.plist create mode 100644 modules/Share-with-Bluesky/Share-with-Bluesky.entitlements create mode 100644 modules/Share-with-Bluesky/ShareViewController.swift create mode 100644 modules/expo-receive-android-intents/README.md create mode 100644 modules/expo-receive-android-intents/android/.gitignore create mode 100644 modules/expo-receive-android-intents/android/build.gradle create mode 100644 modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml create mode 100644 modules/expo-receive-android-intents/android/src/main/java/xyz/blueskyweb/app/exporeceiveandroidintents/ExpoReceiveAndroidIntentsModule.kt create mode 100644 modules/expo-receive-android-intents/expo-module.config.json create mode 100644 plugins/shareExtension/README.md create mode 100644 plugins/shareExtension/withAppEntitlements.js create mode 100644 plugins/shareExtension/withExtensionEntitlements.js create mode 100644 plugins/shareExtension/withExtensionInfoPlist.js create mode 100644 plugins/shareExtension/withExtensionViewController.js create mode 100644 plugins/shareExtension/withIntentFilters.js create mode 100644 plugins/shareExtension/withShareExtensions.js create mode 100644 plugins/shareExtension/withXcodeTarget.js create mode 100644 scripts/README.md create mode 100755 scripts/updateExtensions.sh diff --git a/app.config.js b/app.config.js index 5bbe864a..fa9735dc 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 00000000..90fe9234 --- /dev/null +++ b/modules/Share-with-Bluesky/Info.plist @@ -0,0 +1,41 @@ + + + + + NSExtension + + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsText + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + NSExtensionActivationSupportsImageWithMaxCount + 10 + + + NSExtensionPointIdentifier + com.apple.share-services + + MainAppScheme + bluesky + CFBundleName + $(PRODUCT_NAME) + CFBundleDisplayName + Extension + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + $(MARKETING_VERSION) + + 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 00000000..22ca9157 --- /dev/null +++ b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.xyz.blueskyweb.app + + + diff --git a/modules/Share-with-Bluesky/ShareViewController.swift b/modules/Share-with-Bluesky/ShareViewController.swift new file mode 100644 index 00000000..a16a290b --- /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 00000000..7e850686 --- /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 00000000..877b87e9 --- /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 00000000..3712dda4 --- /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 00000000..bdae66c8 --- /dev/null +++ b/modules/expo-receive-android-intents/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + 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 00000000..c2e17fb8 --- /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().take(4)) + } + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)?.let { + handleImageIntents(it.filterIsInstance().take(4)) + } + } + } + + private fun handleImageIntents(uris: List) { + 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 { + 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 { + 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 00000000..8f01fb6c --- /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 2d520b4b..e9dd9202 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 00000000..2b57e624 --- /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. +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 00000000..6f9136c3 --- /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 00000000..e6bbf9d2 --- /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 00000000..9afc4d5f --- /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 00000000..cd29bea7 --- /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 00000000..605fcfd0 --- /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 00000000..55a26c75 --- /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 00000000..4f43c092 --- /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 00000000..99d6236f --- /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 00000000..f4e462b7 --- /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 249e6898..de9a96da 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 04023bf8..9c8c1301 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) { + *add(image_: Omit) { 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 696a3c5b..c9dbfbea 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 1ed6b98a..2855d423 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([]) const [threadgate, setThreadgate] = useState([]) const [suggestedLinks, setSuggestedLinks] = useState>(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 d37ff4fb..1937fcb6 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} /> ) diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 99e659d6..00233f66 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} />