feat: On Android, you can now share text & files from other apps directly into Standard Notes (#2352)

This commit is contained in:
Aman Harwara
2023-07-12 00:16:32 +05:30
committed by GitHub
parent 1f5354c816
commit 3dc179c7b0
16 changed files with 535 additions and 15 deletions

View File

@@ -43,6 +43,16 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application>

View File

@@ -1,6 +1,7 @@
package com.standardnotes;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.content.res.Configuration;
@@ -54,4 +55,10 @@ public class MainActivity extends ReactActivity {
public void invokeDefaultOnBackPressed() {
moveTaskToBack(true);
}
@Override
public void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
}
}

View File

@@ -39,6 +39,7 @@ public class MainApplication extends Application implements ReactApplication {
packages.add(new Fido2ApiPackage());
packages.add(new CustomWebViewPackage());
packages.add(new ReceiveSharingIntentPackage());
return packages;
}

View File

@@ -0,0 +1,171 @@
// Adapted from
// https://github.com/ajith-ab/react-native-receive-sharing-intent
package com.standardnotes;
import android.annotation.SuppressLint;
import android.app.Application;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.provider.OpenableColumns;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeMap;
import java.util.ArrayList;
import java.util.Objects;
public class ReceiveSharingIntentHelper {
private Context context;
public ReceiveSharingIntentHelper(Application context) {
this.context = context;
}
public void sendFileNames(Context context, Intent intent, Promise promise) {
try {
String action = intent.getAction();
String type = intent.getType();
if (type == null) {
return;
}
if (!type.startsWith("text") && (Objects.equals(action, Intent.ACTION_SEND) || Objects.equals(action, Intent.ACTION_SEND_MULTIPLE))) {
WritableMap files = getMediaUris(intent, context);
if (files == null) return;
promise.resolve(files);
}
else if (type.startsWith("text") && Objects.equals(action, Intent.ACTION_SEND)) {
String text = null;
String subject = null;
try {
text = intent.getStringExtra(Intent.EXTRA_TEXT);
subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
} catch (Exception ignored) {}
WritableMap files;
if (text == null) {
files = getMediaUris(intent, context);
if (files == null) return;
}
else {
files = new WritableNativeMap();
WritableMap file = new WritableNativeMap();
file.putString("contentUri", null);
file.putString("fileName", null);
file.putString("extension", null);
if (text.startsWith("http")) {
file.putString("weblink", text);
file.putString("text", null);
} else {
file.putString("weblink", null);
file.putString("text", text);
}
file.putString("subject", subject);
files.putMap("0", file);
}
promise.resolve(files);
}
else if (Objects.equals(action, Intent.ACTION_VIEW)) {
String link = intent.getDataString();
WritableMap files = new WritableNativeMap();
WritableMap file = new WritableNativeMap();
file.putString("contentUri", null);
file.putString("mimeType", null);
file.putString("text", null);
file.putString("weblink", link);
file.putString("fileName", null);
file.putString("extension", null);
files.putMap("0", file);
promise.resolve(files);
}
else if (Objects.equals(action, Intent.ACTION_PROCESS_TEXT)) {
String text = null;
try {
text = intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT);
} catch (Exception ignored) {
}
WritableMap files = new WritableNativeMap();
WritableMap file = new WritableNativeMap();
file.putString("contentUri", null);
file.putString("fileName", null);
file.putString("extension", null);
file.putString("weblink", null);
file.putString("text", text);
files.putMap("0", file);
promise.resolve(files);
}
else {
promise.reject("error", "Invalid file type.");
}
} catch (Exception e) {
promise.reject("error", e.toString());
}
}
@SuppressLint("Range")
public WritableMap getMediaUris(Intent intent, Context context) {
if (intent == null) return null;
String subject = null;
try {
subject = intent.getStringExtra(Intent.EXTRA_SUBJECT);
} catch (Exception ignored) {
}
WritableMap files = new WritableNativeMap();
if (Objects.equals(intent.getAction(), Intent.ACTION_SEND)) {
WritableMap file = new WritableNativeMap();
Uri contentUri = intent.getParcelableExtra(Intent.EXTRA_STREAM);
if (contentUri == null) return null;
ContentResolver contentResolver = context.getContentResolver();
file.putString("mimeType", contentResolver.getType(contentUri));
Cursor queryResult = contentResolver.query(contentUri, null, null, null, null);
queryResult.moveToFirst();
file.putString("fileName", queryResult.getString(queryResult.getColumnIndex(OpenableColumns.DISPLAY_NAME)));
file.putString("contentUri", contentUri.toString());
file.putString("text", null);
file.putString("weblink", null);
file.putString("subject", subject);
files.putMap("0", file);
queryResult.close();
} else if (Objects.equals(intent.getAction(), Intent.ACTION_SEND_MULTIPLE)) {
ArrayList<Uri> contentUris = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
if (contentUris != null) {
int index = 0;
for (Uri uri : contentUris) {
WritableMap file = new WritableNativeMap();
ContentResolver contentResolver = context.getContentResolver();
// Based on https://developer.android.com/training/secure-file-sharing/retrieve-info
file.putString("mimeType", contentResolver.getType(uri));
Cursor queryResult = contentResolver.query(uri, null, null, null, null);
queryResult.moveToFirst();
file.putString("fileName", queryResult.getString(queryResult.getColumnIndex(OpenableColumns.DISPLAY_NAME)));
file.putString("contentUri", uri.toString());
file.putString("text", null);
file.putString("weblink", null);
file.putString("subject", subject);
files.putMap(Integer.toString(index), file);
queryResult.close();
index++;
}
}
}
return files;
}
public void clearFileNames(Intent intent) {
String type = intent.getType();
if (type == null) return;
if (type.startsWith("text")) {
intent.removeExtra(Intent.EXTRA_TEXT);
} else if (type.startsWith("image") || type.startsWith("video") || type.startsWith("application")) {
intent.removeExtra(Intent.EXTRA_STREAM);
}
}
}

View File

@@ -0,0 +1,64 @@
// Adapted from
// https://github.com/ajith-ab/react-native-receive-sharing-intent
package com.standardnotes;
import android.app.Activity;
import android.app.Application;
import android.content.Intent;
import android.os.Build;
import androidx.annotation.RequiresApi;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
public class ReceiveSharingIntentModule extends ReactContextBaseJavaModule {
public final String Log_Tag = "ReceiveSharingIntent";
private final ReactApplicationContext reactContext;
private ReceiveSharingIntentHelper receiveSharingIntentHelper;
public ReceiveSharingIntentModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
Application applicationContext = (Application) reactContext.getApplicationContext();
receiveSharingIntentHelper = new ReceiveSharingIntentHelper(applicationContext);
}
protected void onNewIntent(Intent intent) {
Activity mActivity = getCurrentActivity();
if(mActivity == null) { return; }
mActivity.setIntent(intent);
}
@RequiresApi(api = Build.VERSION_CODES.KITKAT)
@ReactMethod
public void getFileNames(Promise promise){
Activity mActivity = getCurrentActivity();
if(mActivity == null) { return; }
Intent intent = mActivity.getIntent();
if(intent == null) { return; }
receiveSharingIntentHelper.sendFileNames(reactContext, intent, promise);
mActivity.setIntent(null);
}
@ReactMethod
public void clearFileNames(){
Activity mActivity = getCurrentActivity();
if(mActivity == null) { return; }
Intent intent = mActivity.getIntent();
if(intent == null) { return; }
receiveSharingIntentHelper.clearFileNames(intent);
}
@Override
public String getName() {
return "ReceiveSharingIntent";
}
}

View File

@@ -0,0 +1,27 @@
package com.standardnotes;
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import com.standardnotes.ReceiveSharingIntentModule;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ReceiveSharingIntentPackage implements ReactPackage {
@Override
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@Override
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new ReceiveSharingIntentModule(reactContext));
return modules;
}
}

View File

@@ -0,0 +1,4 @@
import { requireNativeComponent } from 'react-native'
import { NativeWebViewAndroid } from 'react-native-webview/lib/WebViewTypes'
export default requireNativeComponent('CustomWebView') as NativeWebViewAndroid

View File

@@ -2,6 +2,7 @@ import SNReactNative from '@standardnotes/react-native-utils'
import {
AppleIAPProductId,
AppleIAPReceipt,
ApplicationEvent,
ApplicationIdentifier,
DatabaseKeysLoadChunkResponse,
DatabaseLoadOptions,
@@ -57,11 +58,13 @@ export enum MobileDeviceEvent {
}
type MobileDeviceEventHandler = (event: MobileDeviceEvent) => void
type ApplicationEventHandler = (event: ApplicationEvent) => void
export class MobileDevice implements MobileDeviceInterface {
environment: Environment.Mobile = Environment.Mobile
platform: SNPlatform.Ios | SNPlatform.Android = Platform.OS === 'ios' ? SNPlatform.Ios : SNPlatform.Android
private eventObservers: MobileDeviceEventHandler[] = []
private applicationEventObservers: ApplicationEventHandler[] = []
private mobileDeviceEventObservers: MobileDeviceEventHandler[] = []
public isDarkMode = false
public statusBarBgColor: string | undefined
private componentUrls: Map<UuidString, string> = new Map()
@@ -346,13 +349,23 @@ export class MobileDevice implements MobileDeviceInterface {
}
performSoftReset() {
this.notifyEvent(MobileDeviceEvent.RequestsWebViewReload)
this.notifyMobileDeviceEvent(MobileDeviceEvent.RequestsWebViewReload)
}
addMobileWebEventReceiver(handler: MobileDeviceEventHandler): () => void {
this.eventObservers.push(handler)
addMobileDeviceEventReceiver(handler: MobileDeviceEventHandler): () => void {
this.mobileDeviceEventObservers.push(handler)
const thislessObservers = this.eventObservers
const thislessObservers = this.mobileDeviceEventObservers
return () => {
removeFromArray(thislessObservers, handler)
}
}
addApplicationEventReceiver(handler: ApplicationEventHandler): () => void {
this.applicationEventObservers.push(handler)
const thislessObservers = this.applicationEventObservers
return () => {
removeFromArray(thislessObservers, handler)
@@ -373,8 +386,14 @@ export class MobileDevice implements MobileDeviceInterface {
StatusBar.setBarStyle(this.isDarkMode ? 'light-content' : 'dark-content', animated)
}
private notifyEvent(event: MobileDeviceEvent): void {
for (const handler of this.eventObservers) {
private notifyMobileDeviceEvent(event: MobileDeviceEvent): void {
for (const handler of this.mobileDeviceEventObservers) {
handler(event)
}
}
notifyApplicationEvent(event: ApplicationEvent): void {
for (const handler of this.applicationEventObservers) {
handler(event)
}
}

View File

@@ -1,16 +1,16 @@
import { ReactNativeToWebEvent } from '@standardnotes/snjs'
import { ApplicationEvent, ReactNativeToWebEvent } from '@standardnotes/snjs'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Button, Keyboard, Platform, requireNativeComponent, Text, View } from 'react-native'
import { Button, Keyboard, Platform, Text, View } from 'react-native'
import VersionInfo from 'react-native-version-info'
import { WebView, WebViewMessageEvent } from 'react-native-webview'
import { NativeWebViewAndroid, OnShouldStartLoadWithRequest } from 'react-native-webview/lib/WebViewTypes'
import { OnShouldStartLoadWithRequest } from 'react-native-webview/lib/WebViewTypes'
import { AndroidBackHandlerService } from './AndroidBackHandlerService'
import { AppStateObserverService } from './AppStateObserverService'
import { ColorSchemeObserverService } from './ColorSchemeObserverService'
import CustomAndroidWebView from './CustomAndroidWebView'
import { MobileDevice, MobileDeviceEvent } from './Lib/MobileDevice'
import { IsDev } from './Lib/Utils'
const CustomWebView: NativeWebViewAndroid = requireNativeComponent('CustomWebView')
import { ReceivedSharedItemsHandler } from './ReceivedSharedItemsHandler'
const LoggingEnabled = IsDev
@@ -110,7 +110,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
}, [webViewRef, stateService, device, androidBackHandlerService, colorSchemeService])
useEffect(() => {
const observer = device.addMobileWebEventReceiver((event) => {
const observer = device.addMobileDeviceEventReceiver((event) => {
if (event === MobileDeviceEvent.RequestsWebViewReload) {
destroyAndReload()
}
@@ -283,6 +283,21 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
const requireInlineMediaPlaybackForMomentsFeature = true
const requireMediaUserInteractionForMomentsFeature = false
const receivedSharedItemsHandler = useRef(new ReceivedSharedItemsHandler(webViewRef))
useEffect(() => {
const receivedSharedItemsHandlerInstance = receivedSharedItemsHandler.current
return () => {
receivedSharedItemsHandlerInstance.deinit()
}
}, [])
useEffect(() => {
return device.addApplicationEventReceiver((event) => {
if (event === ApplicationEvent.Launched) {
receivedSharedItemsHandler.current.setIsApplicationLaunched(true)
}
})
}, [device])
if (showAndroidWebviewUpdatePrompt) {
return (
<View
@@ -357,7 +372,7 @@ const MobileWebAppContents = ({ destroyAndReload }: { destroyAndReload: () => vo
overScrollMode="never"
nativeConfig={Platform.select({
android: {
component: CustomWebView,
component: CustomAndroidWebView,
},
})}
/>

View File

@@ -0,0 +1,132 @@
import { ReactNativeToWebEvent } from '@standardnotes/snjs'
import { RefObject } from 'react'
import { AppState, NativeEventSubscription, NativeModules } from 'react-native'
import { readFile } from 'react-native-fs'
import WebView from 'react-native-webview'
const { ReceiveSharingIntent } = NativeModules
type ReceivedItem = {
contentUri?: string | null
fileName?: string | null
mimeType?: string | null
extension?: string | null
text?: string | null
weblink?: string | null
subject?: string | null
}
type ReceivedFile = ReceivedItem & {
contentUri: string
mimeType: string
}
type ReceivedWeblink = ReceivedItem & {
weblink: string
}
type ReceivedText = ReceivedItem & {
text: string
}
const isReceivedFile = (item: ReceivedItem): item is ReceivedFile => {
return !!item.contentUri && !!item.mimeType
}
const isReceivedWeblink = (item: ReceivedItem): item is ReceivedWeblink => {
return !!item.weblink
}
const isReceivedText = (item: ReceivedItem): item is ReceivedText => {
return !!item.text
}
export class ReceivedSharedItemsHandler {
private appStateEventSub: NativeEventSubscription | null = null
private receivedItemsQueue: ReceivedItem[] = []
private isApplicationLaunched = false
constructor(private webViewRef: RefObject<WebView>) {
this.registerNativeEventSub()
}
setIsApplicationLaunched = (isApplicationLaunched: boolean) => {
this.isApplicationLaunched = isApplicationLaunched
if (isApplicationLaunched) {
this.handleItemsQueue().catch(console.error)
}
}
deinit() {
this.receivedItemsQueue = []
this.appStateEventSub?.remove()
}
private registerNativeEventSub = () => {
this.appStateEventSub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
ReceiveSharingIntent.getFileNames()
.then(async (filesObject: Record<string, ReceivedItem>) => {
const items = Object.values(filesObject)
this.receivedItemsQueue.push(...items)
if (this.isApplicationLaunched) {
this.handleItemsQueue().catch(console.error)
}
})
.then(() => ReceiveSharingIntent.clearFileNames())
.catch(console.error)
}
})
}
handleItemsQueue = async () => {
if (!this.receivedItemsQueue.length) {
return
}
const item = this.receivedItemsQueue.shift()
if (!item) {
return
}
if (isReceivedFile(item)) {
const data = await readFile(item.contentUri, 'base64')
const file = {
name: item.fileName || item.contentUri,
data,
mimeType: item.mimeType,
}
this.webViewRef.current?.postMessage(
JSON.stringify({
reactNativeEvent: ReactNativeToWebEvent.ReceivedFile,
messageType: 'event',
messageData: file,
}),
)
} else if (isReceivedWeblink(item)) {
this.webViewRef.current?.postMessage(
JSON.stringify({
reactNativeEvent: ReactNativeToWebEvent.ReceivedText,
messageType: 'event',
messageData: {
title: item.subject || item.weblink,
text: item.weblink,
},
}),
)
} else if (isReceivedText(item)) {
this.webViewRef.current?.postMessage(
JSON.stringify({
reactNativeEvent: ReactNativeToWebEvent.ReceivedText,
messageType: 'event',
messageData: {
text: item.text,
},
}),
)
}
this.handleItemsQueue().catch(console.error)
}
}

View File

@@ -3,6 +3,7 @@ import { Environment, Platform, RawKeychainValue } from '@standardnotes/models'
import { AppleIAPProductId } from './../Subscription/AppleIAPProductId'
import { DeviceInterface } from './DeviceInterface'
import { AppleIAPReceipt } from '../Subscription/AppleIAPReceipt'
import { ApplicationEvent } from '../Event/ApplicationEvent'
export interface MobileDeviceInterface extends DeviceInterface {
environment: Environment.Mobile
@@ -27,4 +28,5 @@ export interface MobileDeviceInterface extends DeviceInterface {
getColorScheme(): Promise<'light' | 'dark' | null | undefined>
purchaseSubscriptionIAP(plan: AppleIAPProductId): Promise<AppleIAPReceipt | undefined>
authenticateWithU2F(authenticationOptionsJSONString: string): Promise<Record<string, unknown> | null>
notifyApplicationEvent(event: ApplicationEvent): void
}

View File

@@ -9,4 +9,6 @@ export enum ReactNativeToWebEvent {
KeyboardFrameDidChange = 'KeyboardFrameDidChange',
KeyboardWillShow = 'KeyboardWillShow',
KeyboardWillHide = 'KeyboardWillHide',
ReceivedFile = 'ReceivedFile',
ReceivedText = 'ReceivedText',
}

View File

@@ -15,6 +15,8 @@ export interface WebApplicationInterface extends ApplicationInterface {
handleMobileColorSchemeChangeEvent(): void
handleMobileKeyboardWillChangeFrameEvent(frame: { height: number; contentHeight: number }): void
handleMobileKeyboardDidChangeFrameEvent(frame: { height: number; contentHeight: number }): void
handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void
handleReceivedTextEvent(item: { text: string; title?: string }): Promise<void>
isNativeMobileWeb(): boolean
mobileDevice(): MobileDeviceInterface
handleAndroidBackButtonPressed(): void

View File

@@ -24,11 +24,13 @@ import {
BackupServiceInterface,
InternalFeatureService,
InternalFeatureServiceInterface,
NoteContent,
SNNote,
} from '@standardnotes/snjs'
import { makeObservable, observable } from 'mobx'
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
import { PanelResizedData } from '@/Types/PanelResizedData'
import { isAndroid, isDesktopApplication, isDev, isIOS } from '@/Utils'
import { getBlobFromBase64, isAndroid, isDesktopApplication, isDev, isIOS } from '@/Utils'
import { DesktopManager } from './Device/DesktopManager'
import {
ArchiveManager,
@@ -66,6 +68,7 @@ export class WebApplication extends SNApplication implements WebApplicationInter
private readonly mobileWebReceiver?: MobileWebReceiver
private readonly androidBackHandler?: AndroidBackHandler
private readonly visibilityObserver?: VisibilityObserver
private readonly mobileAppEventObserver?: () => void
public readonly devMode?: DevMode
@@ -137,6 +140,9 @@ export class WebApplication extends SNApplication implements WebApplicationInter
if (this.isNativeMobileWeb()) {
this.mobileWebReceiver = new MobileWebReceiver(this)
this.androidBackHandler = new AndroidBackHandler()
this.mobileAppEventObserver = this.addEventObserver(async (event) => {
this.mobileDevice().notifyApplicationEvent(event)
})
// eslint-disable-next-line no-console
console.log = (...args) => {
@@ -186,6 +192,11 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.visibilityObserver.deinit()
;(this.visibilityObserver as unknown) = undefined
}
if (this.mobileAppEventObserver) {
this.mobileAppEventObserver()
;(this.mobileAppEventObserver as unknown) = undefined
}
} catch (error) {
console.error('Error while deiniting application', error)
}
@@ -376,6 +387,27 @@ export class WebApplication extends SNApplication implements WebApplicationInter
this.notifyWebEvent(WebAppEvent.MobileKeyboardDidChangeFrame, frame)
}
handleReceivedFileEvent(file: { name: string; mimeType: string; data: string }): void {
const filesController = this.getViewControllerManager().filesController
const blob = getBlobFromBase64(file.data, file.mimeType)
const mappedFile = new File([blob], file.name, { type: file.mimeType })
void filesController.uploadNewFile(mappedFile, true)
}
async handleReceivedTextEvent({ text, title }: { text: string; title?: string | undefined }) {
const titleForNote = title || this.getViewControllerManager().itemListController.titleForNewNote()
const note = this.items.createTemplateItem<NoteContent, SNNote>(ContentType.Note, {
title: titleForNote,
text: text,
references: [],
})
const insertedNote = await this.mutator.insertItem(note)
this.getViewControllerManager().selectionController.selectItem(insertedNote.uuid, true).catch(console.error)
}
private async lockApplicationAfterMobileEventIfApplicable(): Promise<void> {
const isLocked = await this.isLocked()
if (isLocked) {

View File

@@ -83,6 +83,18 @@ export class MobileWebReceiver {
messageData as { height: number; contentHeight: number },
)
break
case ReactNativeToWebEvent.ReceivedFile:
void this.application.handleReceivedFileEvent(
messageData as {
name: string
mimeType: string
data: string
},
)
break
case ReactNativeToWebEvent.ReceivedText:
void this.application.handleReceivedTextEvent(messageData as { text: string; title?: string })
break
default:
break

View File

@@ -229,3 +229,23 @@ export const getBase64FromBlob = (blob: Blob) => {
reader.readAsDataURL(blob)
})
}
export const getBlobFromBase64 = (b64Data: string, contentType = '', sliceSize = 512) => {
const byteCharacters = atob(b64Data)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
const slice = byteCharacters.slice(offset, offset + sliceSize)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
const blob = new Blob(byteArrays, { type: contentType })
return blob
}