diff --git a/rnmodules/kb-common/src/ItemProviderHelper.swift b/rnmodules/kb-common/src/ItemProviderHelper.swift index 5e6843f8ee1c..e8114ff8a7c7 100644 --- a/rnmodules/kb-common/src/ItemProviderHelper.swift +++ b/rnmodules/kb-common/src/ItemProviderHelper.swift @@ -173,30 +173,26 @@ public class ItemProviderHelper: NSObject { } private func handleAndCompleteMediaFile(_ url: URL, isVideo: Bool) { - if isVideo { - MediaUtils.processVideo(fromOriginal: url) { error, scaled in - if let error = error { + let completion: (Error?, URL?) -> Void = { error, scaled in + if let error = error { + if isVideo { self.completeItemAndAppendManifestAndLogError( text: "handleAndCompleteMediaFile", error: error) - return + } else { + // Unscalable image (odd format, corrupt, etc): still deliver the original + self.completeItemAndAppendManifest(type: "file", originalFileURL: url) } - self.completeItemAndAppendManifest( - type: isVideo ? "video" : "image", - originalFileURL: url, - scaledFileURL: scaled!) + return } + self.completeItemAndAppendManifest( + type: isVideo ? "video" : "image", + originalFileURL: url, + scaledFileURL: scaled!) + } + if isVideo { + MediaUtils.processVideo(fromOriginal: url, completion: completion) } else { - MediaUtils.processImage(fromOriginal: url) { error, scaled in - if let error = error { - self.completeItemAndAppendManifestAndLogError( - text: "handleAndCompleteMediaFile", error: error) - return - } - self.completeItemAndAppendManifest( - type: isVideo ? "video" : "image", - originalFileURL: url, - scaledFileURL: scaled!) - } + MediaUtils.processImage(fromOriginal: url, completion: completion) } } @@ -315,25 +311,23 @@ public class ItemProviderHelper: NSObject { } } - private func sendMovie(_ url: URL?) { - var filePayloadURL: URL? - var error: Error? - - if let url = url { - filePayloadURL = getPayloadURL(from: url) - do { - try FileManager.default.copyItem(at: url, to: filePayloadURL!) - } catch let copyError { - error = copyError - } + // Copy the provider's temporary file into our payload folder (it's deleted when + // the completion handler returns), then scale it so the manifest carries both + // original and compressed versions. + private func sendMedia(_ url: URL?, isVideo: Bool) { + guard let url = url else { + completeItemAndAppendManifestAndLogError(text: "sendMedia: unable to decode share", error: nil) + return } - if let filePayloadURL = filePayloadURL, error == nil { - handleAndCompleteMediaFile(filePayloadURL, isVideo: true) - } else { - completeItemAndAppendManifestAndLogError( - text: "movieFileHandlerSimple2: copy error", error: error) + let filePayloadURL = getPayloadURL(from: url) + do { + try FileManager.default.copyItem(at: url, to: filePayloadURL) + } catch { + completeItemAndAppendManifestAndLogError(text: "sendMedia: copy error", error: error) + return } + handleAndCompleteMediaFile(filePayloadURL, isVideo: isVideo) } private func sendImage(_ imgData: Data?) { @@ -363,7 +357,11 @@ public class ItemProviderHelper: NSObject { } let movieHandler: @Sendable (URL?, Error?) -> Void = { url, error in - self.sendMovie(error == nil ? url : nil) + self.sendMedia(error == nil ? url : nil, isVideo: true) + } + + let imageFileHandler: @Sendable (URL?, Error?) -> Void = { url, error in + self.sendMedia(error == nil ? url : nil, isVideo: false) } let imageHandler: @Sendable (NSSecureCoding?, Error?) -> Void = { item, error in @@ -430,17 +428,12 @@ public class ItemProviderHelper: NSObject { item.loadFileRepresentation(forTypeIdentifier: stype, completionHandler: movieHandler) break } - // Images (PNG, GIF, JPEG) - else if type.conforms(to: .png) || type.conforms(to: .gif) || type.conforms(to: .jpeg) { - incrementUnprocessed() - item.loadFileRepresentation(forTypeIdentifier: stype, completionHandler: fileHandler) - break - } - // HEIC Images - else if stype == "public.heic" { + // Images (PNG, GIF, JPEG, HEIC): keep the original file, offer a scaled version + else if type.conforms(to: .png) || type.conforms(to: .gif) || type.conforms(to: .jpeg) + || stype == "public.heic" + { incrementUnprocessed() - item.loadFileRepresentation( - forTypeIdentifier: "public.heic", completionHandler: fileHandler) + item.loadFileRepresentation(forTypeIdentifier: stype, completionHandler: imageFileHandler) break } // Other Images (coerce) diff --git a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt index 4812f2e4079c..840bbe3a74d5 100644 --- a/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt +++ b/shared/android/app/src/main/java/io/keybase/ossifrage/MainActivity.kt @@ -60,18 +60,7 @@ class MainActivity : ReactActivity() { override fun onCreate(savedInstanceState: Bundle?) { NativeLogger.info("Activity onCreate") setupKBRuntime(this, true) - cachedIntent = intent - if (Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action) { - normalizeShareIntent(intent) - cachedIntent = intent - pendingShareUris = extractSharedUris(intent).toMutableList() - pendingShareSubject = intent.getStringExtra(Intent.EXTRA_SUBJECT) - pendingShareText = intent.getStringExtra(Intent.EXTRA_TEXT) - } - val bundleFromNotification = intent.getBundleExtra("notification") - if (bundleFromNotification != null) { - KbModule.setInitialNotification(bundleFromNotification.clone() as Bundle) - } + captureIntent(intent) super.onCreate(null) Handler(Looper.getMainLooper()).postDelayed({ @@ -184,19 +173,17 @@ class MainActivity : ReactActivity() { private var cachedIntent: Intent? = null - private var pendingShareUris: MutableList? = null + private var pendingShareUris: List? = null private var pendingShareSubject: String? = null private var pendingShareText: String? = null - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) + // Snapshot share/notification data out of the intent right away: share URI + // permission grants and clip data are tied to the delivered intent, and JS may + // not be ready to consume them until much later (see tryHandleIntentWithRetry). + private fun captureIntent(intent: Intent) { cachedIntent = intent if (Intent.ACTION_SEND == intent.action || Intent.ACTION_SEND_MULTIPLE == intent.action) { - normalizeShareIntent(intent) - setIntent(intent) - cachedIntent = intent - pendingShareUris = extractSharedUris(intent).toMutableList() + pendingShareUris = extractSharedUris(intent) pendingShareSubject = intent.getStringExtra(Intent.EXTRA_SUBJECT) pendingShareText = intent.getStringExtra(Intent.EXTRA_TEXT) } @@ -204,7 +191,13 @@ class MainActivity : ReactActivity() { if (bundleFromNotification != null) { KbModule.setInitialNotification(bundleFromNotification.clone() as Bundle) } - NativeLogger.info("MainActivity.onNewIntent: action=${intent.action}, uriCount=${pendingShareUris?.size ?: 0}, hasNotification=${bundleFromNotification != null}") + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + captureIntent(intent) + NativeLogger.info("MainActivity.onNewIntent: action=${intent.action}, uriCount=${pendingShareUris?.size ?: 0}, hasNotification=${intent.getBundleExtra("notification") != null}") } private var jsIsListening = false @@ -216,14 +209,6 @@ class MainActivity : ReactActivity() { private var handledIntentHash: String? = null - private fun normalizeShareIntent(intent: Intent) { - val uris = extractSharedUris(intent) - intent.removeExtra(Intent.EXTRA_STREAM) - if (uris.isNotEmpty()) { - intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(uris)) - } - } - private fun extractSharedUris(intent: Intent): List { val action = intent.action if (Intent.ACTION_SEND != action && Intent.ACTION_SEND_MULTIPLE != action) { @@ -292,34 +277,20 @@ class MainActivity : ReactActivity() { // If other sources start the app, we can get their intent data the same way. val bundleFromNotification = intent.getBundleExtra("notification") - var didSomething = false - if (bundleFromNotification != null) { // Prevent duplicate handling of the same notification val convID = bundleFromNotification.getString("convID") ?: bundleFromNotification.getString("c") val messageId = bundleFromNotification.getString("msgID") ?: bundleFromNotification.getString("d") ?: "" val intentHash = "${convID}_${messageId}" - val shouldEmitNotification = handledIntentHash != intentHash - if (!shouldEmitNotification) { + if (handledIntentHash == intentHash) { NativeLogger.info("MainActivity.handleIntent skipping duplicate notification: $intentHash") } else { handledIntentHash = intentHash NativeLogger.info("MainActivity.handleIntent processing notification: $intentHash") // If there are any other bundle sources we care about, emit them here - val bundle1 = bundleFromNotification.clone() as Bundle - val bundle2 = bundleFromNotification.clone() as Bundle - val payload1 = Arguments.fromBundle(bundle1) - rc.emitDeviceEvent( - "initialIntentFromNotification", - payload1 - ) - val payload2 = Arguments.fromBundle(bundle2) - rc.emitDeviceEvent( - "onPushNotification", - payload2 - ) - didSomething = true + rc.emitDeviceEvent("initialIntentFromNotification", Arguments.fromBundle(bundleFromNotification)) + rc.emitDeviceEvent("onPushNotification", Arguments.fromBundle(bundleFromNotification)) } intent.removeExtra("notification") @@ -327,67 +298,47 @@ class MainActivity : ReactActivity() { val action = intent.action if (Intent.ACTION_SEND == action || Intent.ACTION_SEND_MULTIPLE == action) { - val uris = pendingShareUris?.also { pendingShareUris = null } - ?: extractSharedUris(intent) - val subject = pendingShareSubject?.also { pendingShareSubject = null } - ?: intent.getStringExtra(Intent.EXTRA_SUBJECT) - val text = pendingShareText?.also { pendingShareText = null } - ?: intent.getStringExtra(Intent.EXTRA_TEXT) + val uris = pendingShareUris.orEmpty().also { pendingShareUris = null } + val subject = pendingShareSubject.also { pendingShareSubject = null } + val text = pendingShareText.also { pendingShareText = null } + // Strip consumed extras so an activity recreation (which redelivers this + // same intent instance) doesn't re-share. intent.removeExtra(Intent.EXTRA_STREAM) intent.removeExtra(Intent.EXTRA_SUBJECT) intent.removeExtra(Intent.EXTRA_TEXT) intent.setClipData(null) - val sb = StringBuilder() - if (subject != null) { - sb.append(subject) - } - if (subject != null && text != null) { - sb.append(" ") - } - if (text != null) { - sb.append(text) - } - - val textPayload = sb.toString() - val filePaths = uris.mapNotNull { uri -> - try { - readFileFromUri(rc, uri) - } catch (e: SecurityException) { - null - } - }.toTypedArray() - - val intentType = intent.type - val isTextMime = intentType?.startsWith("text/") == true + val textPayload = listOfNotNull(subject, text).joinToString(" ") + val isTextMime = intent.type?.startsWith("text/") == true if (isTextMime && textPayload.isNotEmpty()) { // Text-type intent (e.g. URL from Chrome): prefer text over any preview images - val args = Arguments.createMap() - args.putString("text", text ?: textPayload) - rc.emitDeviceEvent("onShareData", args) - didSomething = true - } else if (filePaths.isNotEmpty()) { - val args = Arguments.createMap() - val lPaths = Arguments.createArray() - for (path in filePaths) { - lPaths.pushString(path) + emitShareText(rc, text ?: textPayload) + } else if (uris.isEmpty()) { + if (textPayload.isNotEmpty()) { + emitShareText(rc, textPayload) } - args.putArray("localPaths", lPaths) - rc.emitDeviceEvent("onShareData", args) - didSomething = true - } else if (textPayload.isNotEmpty()) { - // Fallback: non-text MIME but no files resolved, send text - val args = Arguments.createMap() - args.putString("text", textPayload) - rc.emitDeviceEvent("onShareData", args) - didSomething = true - } else if (uris.isNotEmpty()) { - val args = Arguments.createMap() - args.putArray("localPaths", Arguments.createArray()) - rc.emitDeviceEvent("onShareData", args) - didSomething = true + } else { + // Copying out of the content providers can be slow for big files; don't + // block the main thread on it. + Thread { + val filePaths = uris.mapNotNull { uri -> + try { + readFileFromUri(rc, uri) + } catch (e: SecurityException) { + null + } + } + if (filePaths.isNotEmpty()) { + emitShareFiles(rc, filePaths) + } else if (textPayload.isNotEmpty()) { + // Fallback: non-text MIME but no files resolved, send text + emitShareText(rc, textPayload) + } else { + emitShareFiles(rc, emptyList()) + } + }.start() } } @@ -395,6 +346,22 @@ class MainActivity : ReactActivity() { return true } + private fun emitShareText(rc: ReactContext, text: String) { + val args = Arguments.createMap() + args.putString("text", text) + rc.emitDeviceEvent("onShareData", args) + } + + private fun emitShareFiles(rc: ReactContext, paths: List) { + val args = Arguments.createMap() + val lPaths = Arguments.createArray() + for (path in paths) { + lPaths.pushString(path) + } + args.putArray("localPaths", lPaths) + rc.emitDeviceEvent("onShareData", args) + } + override fun getMainComponentName(): String = "Keybase" override fun createReactActivityDelegate(): ReactActivityDelegate { diff --git a/shared/incoming-share/index.tsx b/shared/incoming-share/index.tsx index 10539a750750..673d684e8a1f 100644 --- a/shared/incoming-share/index.tsx +++ b/shared/incoming-share/index.tsx @@ -9,7 +9,7 @@ import * as FS from '@/constants/fs' import {useConfigState} from '@/stores/config' import {ensureError} from '@/util/errors' import {getInboxConversationMeta} from '@/chat/inbox/metadata' -import {useSafeAreaFrame} from 'react-native-safe-area-context' +import {IncomingShareHeaderTitle} from './routes' export const getIncomingShareSizes = (incomingShareItems: ReadonlyArray) => { const originalTotalSize = incomingShareItems.reduce((bytes, item) => bytes + (item.originalSize ?? 0), 0) @@ -25,21 +25,15 @@ export const OriginalOrCompressedButton = ({incomingShareItems}: IncomingSharePr const setUseOriginalInStore = useConfigState(s => s.dispatch.setIncomingShareUseOriginal) const setUseOriginalInService = (useOriginal: boolean) => { - T.RPCGen.incomingShareSetPreferenceRpcPromise({ - preference: useOriginal - ? {compressPreference: T.RPCGen.IncomingShareCompressPreference.original} - : {compressPreference: T.RPCGen.IncomingShareCompressPreference.compressed}, - }) - .then(() => {}) - .catch(() => {}) + C.ignorePromise( + T.RPCGen.incomingShareSetPreferenceRpcPromise({ + preference: useOriginal + ? {compressPreference: T.RPCGen.IncomingShareCompressPreference.original} + : {compressPreference: T.RPCGen.IncomingShareCompressPreference.compressed}, + }).catch(() => {}) + ) } - React.useEffect(() => { - if (originalOnly) { - setUseOriginalInStore(true) - } - }, [originalOnly, setUseOriginalInStore]) - const getRPC = C.useRPC(T.RPCGen.incomingShareGetPreferenceRpcPromise) React.useEffect(() => { if (!originalOnly) { @@ -120,7 +114,7 @@ export const getContentDescriptionText = (items: ReadonlyArray 1) { return items.some(({type}) => type !== items[0]?.type) ? `${items.length} items` - : `${items.length} ${incomingShareTypeToString(items[0]!.type, false, true)}` + : `${items.length} ${incomingShareTypeToString(items[0]!.type, true)}` } const item = items[0] @@ -131,25 +125,7 @@ export const getContentDescriptionText = (items: ReadonlyArray { - const {width} = useSafeAreaFrame() - return ( - - {title ? ( - - {title} - - ) : null} - Share to... - - ) + return name || `1 ${incomingShareTypeToString(item.type, false)}` } const useFooter = (incomingShareItems: ReadonlyArray) => { @@ -163,27 +139,23 @@ const useFooter = (incomingShareItems: ReadonlyArray }, }) } - return isChatOnly(incomingShareItems) - ? undefined - : { - content: ( - - - Save in Files - - ), - } + return isChatOnly(incomingShareItems) ? undefined : ( + + + Save in Files + + ) } type IncomingShareProps = { incomingShareItems: ReadonlyArray } -type IncomingShareWithSelectionProps = IncomingShareProps & { +type SelectedConversationProps = { selectedConversationIDKey?: T.Chat.ConversationIDKey } -const IncomingShare = (props: IncomingShareWithSelectionProps) => { +const IncomingShare = (props: IncomingShareProps & SelectedConversationProps) => { const navigateAppend = C.Router2.navigateAppend const navigation = useNavigation() const useOriginalValue = useConfigState(s => s.incomingShareUseOriginal) @@ -262,11 +234,7 @@ const IncomingShare = (props: IncomingShareWithSelectionProps) => { }, [contentDescription, navigation, originalOnly, props.incomingShareItems]) if (canDirectNav) { - return ( - - - - ) + return } return ( @@ -274,7 +242,7 @@ const IncomingShare = (props: IncomingShareWithSelectionProps) => { - {footer ? {footer.content} : null} + {footer ? {footer} : null} ) } @@ -325,14 +293,27 @@ const useIncomingShareItems = () => { : [{content: androidShare.text, type: T.RPCGen.IncomingShareType.text}] : undefined + // The share is consumed by this screen; clear it so a later cold-path + // getInitialURL doesn't resurface a stale share (see router-v2/linking.tsx). + const setAndroidShare = useConfigState(s => s.dispatch.setAndroidShare) + React.useEffect(() => { + return () => { + if (isAndroid) { + setAndroidShare(undefined) + } + } + }, [setAndroidShare]) + return {incomingShareError, incomingShareItems: androidShareItems ?? incomingShareItems} } -type IncomingShareMainProps = { - selectedConversationIDKey?: T.Chat.ConversationIDKey -} +const LoadingSpinner = () => ( + + + +) -const IncomingShareMain = (props: IncomingShareMainProps) => { +const IncomingShareMain = (props: SelectedConversationProps) => { const {incomingShareError, incomingShareItems} = useIncomingShareItems() return incomingShareError ? ( @@ -342,9 +323,7 @@ const IncomingShareMain = (props: IncomingShareMainProps) => { selectedConversationIDKey={props.selectedConversationIDKey} /> ) : ( - - - + ) } @@ -354,25 +333,21 @@ const styles = Kb.Styles.styleSheetCreate(() => ({ }, })) -const incomingShareTypeToString = ( - type: T.RPCGen.IncomingShareType, - capitalize: boolean, - plural: boolean -): string => { +const incomingShareTypeToString = (type: T.RPCGen.IncomingShareType, plural: boolean): string => { switch (type) { case T.RPCGen.IncomingShareType.file: - return (capitalize ? 'File' : 'file') + (plural ? 's' : '') + return 'file' + (plural ? 's' : '') case T.RPCGen.IncomingShareType.text: - return (capitalize ? 'Text snippet' : 'text snippet') + (plural ? 's' : '') + return 'text snippet' + (plural ? 's' : '') case T.RPCGen.IncomingShareType.image: - return (capitalize ? 'Image' : 'image') + (plural ? 's' : '') + return 'image' + (plural ? 's' : '') case T.RPCGen.IncomingShareType.video: - return (capitalize ? 'Video' : 'video') + (plural ? 's' : '') + return 'video' + (plural ? 's' : '') } } -const isChatOnly = (items?: ReadonlyArray): boolean => - items?.length === 1 && +const isChatOnly = (items: ReadonlyArray): boolean => + items.length === 1 && items[0]!.type === T.RPCGen.IncomingShareType.text && !!items[0]!.content && !items[0]!.originalPath diff --git a/shared/incoming-share/routes.tsx b/shared/incoming-share/routes.tsx index 94b91a0d427e..87241fd4f304 100644 --- a/shared/incoming-share/routes.tsx +++ b/shared/incoming-share/routes.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import * as C from '@/constants' import * as Kb from '@/common-adapters' import {defineRouteMap} from '@/constants/types/router' +import {useSafeAreaFrame} from 'react-native-safe-area-context' const IncomingShareHeaderLeft = () => { const clearModals = C.Router2.clearModals @@ -12,11 +13,19 @@ const IncomingShareHeaderLeft = () => { ) } -// Content-sized so the native header centers it in the bar (fullWidth would -// center within the asymmetric space between the left/right items instead) -const IncomingShareHeaderTitle = () => { +// Content-sized so the native header centers it in the bar; fullWidth would fill +// the asymmetric space between the Cancel pill and the right item and center +// within that instead. maxWidth keeps long filenames clear of both sides — same +// trick as fs/nav-header/ios-header. +export const IncomingShareHeaderTitle = ({title}: {title?: string}) => { + const {width} = useSafeAreaFrame() return ( - + + {title ? ( + + {title} + + ) : null} Share to... ) diff --git a/shared/ios/KeybaseShare/ShareViewController.swift b/shared/ios/KeybaseShare/ShareViewController.swift index d85ceda0d1cf..74ce9feb5951 100644 --- a/shared/ios/KeybaseShare/ShareViewController.swift +++ b/shared/ios/KeybaseShare/ShareViewController.swift @@ -62,10 +62,6 @@ public class ShareViewController: UIViewController { present(alertController, animated: true, completion: nil) } - func closeProgressView() { - alert?.dismiss(animated: true, completion: nil) - } - public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) guard !didStartProcessing else { return } diff --git a/shared/router-v2/linking.tsx b/shared/router-v2/linking.tsx index d6d4d6bfb5a5..298fd47e99c7 100644 --- a/shared/router-v2/linking.tsx +++ b/shared/router-v2/linking.tsx @@ -1,6 +1,6 @@ import * as Tabs from '@/constants/tabs' import {isSplit} from '@/constants/chat/layout' -import {isValidConversationIDKey} from '@/constants/types/chat/common' +import {isValidConversationIDKey, stringToConversationIDKey} from '@/constants/types/chat/common' import {useConfigState} from '@/stores/config' import {useCurrentUserState} from '@/stores/current-user' import type * as UsePushStateType from '@/stores/push' @@ -187,9 +187,13 @@ const customGetStateFromPath = ( break } - // keybase://incoming-share + // keybase://incoming-share/{conversationIDKey?} — convID present when the user + // picked a donated conversation directly in the share sheet case 'incoming-share': - return makeModalState('incomingShareNew') + return makeModalState( + 'incomingShareNew', + parts[1] ? {selectedConversationIDKey: stringToConversationIDKey(parts[1])} : undefined + ) // keybase://settingsPushPrompt case 'settingsPushPrompt':