Fix attachment download base64 decoding issue (#1010)

This commit is contained in:
Leendert de Borst
2025-07-26 22:39:11 +02:00
committed by Leendert de Borst
parent 0dac97f4ff
commit 8ddefa56af
8 changed files with 89 additions and 86 deletions

View File

@@ -927,23 +927,7 @@ export class SqliteClient {
for (const attachment of attachments) {
const isExistingAttachment = originalAttachmentIds.includes(attachment.Id);
if (isExistingAttachment) {
// Update existing attachment
const updateQuery = `
UPDATE Attachments
SET Filename = ?,
Blob = ?,
UpdatedAt = ?,
IsDeleted = ?
WHERE Id = ?`;
this.executeUpdate(updateQuery, [
attachment.Filename,
attachment.Blob as Uint8Array,
currentDateTime,
attachment.IsDeleted ? 1 : 0,
attachment.Id
]);
} else {
if (!isExistingAttachment) {
// Insert new attachment
const insertQuery = `
INSERT INTO Attachments (Id, Filename, Blob, CredentialId, CreatedAt, UpdatedAt, IsDeleted)

View File

@@ -9,7 +9,7 @@ import { StyleSheet, View, TouchableOpacity, Alert, Keyboard, KeyboardAvoidingVi
import { KeyboardAwareScrollView } from 'react-native-keyboard-aware-scroll-view';
import Toast from 'react-native-toast-message';
import { CreateIdentityGenerator, IdentityHelperUtils, IdentityGenerator } from '@/utils/dist/shared/identity-generator';
import { CreateIdentityGenerator, IdentityGenerator, IdentityHelperUtils } from '@/utils/dist/shared/identity-generator';
import type { Attachment, Credential } from '@/utils/dist/shared/models/vault';
import type { FaviconExtractModel } from '@/utils/dist/shared/models/webapi';
import { CreatePasswordGenerator, PasswordGenerator } from '@/utils/dist/shared/password-generator';
@@ -145,7 +145,7 @@ export default function AddEditCredentialScreen() : React.ReactNode {
/**
* Initialize the identity and password generators with settings from user's vault.
* @returns {identityGenerator: IdentityGenerator, passwordGenerator: PasswordGenerator}
* @returns {identityGenerator: IIdentityGenerator, passwordGenerator: PasswordGenerator}
*/
const initializeGenerators = useCallback(async () : Promise<{ identityGenerator: IdentityGenerator, passwordGenerator: PasswordGenerator }> => {
// Get default identity language from database
@@ -724,7 +724,6 @@ export default function AddEditCredentialScreen() : React.ReactNode {
<AttachmentUploader
attachments={attachments}
onAttachmentsChange={setAttachments}
originalAttachmentIds={originalAttachmentIds}
/>
</View>

View File

@@ -1,9 +1,10 @@
import { Buffer } from 'buffer';
import { Ionicons } from '@expo/vector-icons';
import * as FileSystem from 'expo-file-system';
import * as Sharing from 'expo-sharing';
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TouchableOpacity, Alert } from 'react-native';
import { View, StyleSheet, TouchableOpacity, Alert, Share } from 'react-native';
import type { Credential, Attachment } from '@/utils/dist/shared/models/vault';
import emitter from '@/utils/EventEmitter';
@@ -32,42 +33,38 @@ export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ credential
*/
const downloadAttachment = async (attachment: Attachment): Promise<void> => {
try {
// Convert Uint8Array or number[] to Uint8Array
const byteArray = attachment.Blob instanceof Uint8Array
? attachment.Blob
: new Uint8Array(attachment.Blob);
const tempFile = `${FileSystem.cacheDirectory}${attachment.Filename}`;
const fileUri = `${FileSystem.documentDirectory}${attachment.Filename}`;
// Step 1: Create a Blob
if (typeof attachment.Blob === 'string') {
// If attachment.Blob is already a base64 string
const base64Data = attachment.Blob;
// Convert byte array to base64
let binary = '';
const bytes = new Uint8Array(byteArray);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
await FileSystem.writeAsStringAsync(tempFile, base64Data, {
encoding: FileSystem.EncodingType.Base64,
});
}
else {
// Convert attachment.Blob to base64
const base64Data = Buffer.from(attachment.Blob as unknown as string, 'base64');
await FileSystem.writeAsStringAsync(tempFile, base64Data.toString(), {
encoding: FileSystem.EncodingType.Base64,
});
}
const base64 = btoa(binary);
// Write file to local storage
await FileSystem.writeAsStringAsync(fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
// Step 2: Share using Sharing API (better handles mime types)
await Share.share({
url: tempFile,
title: attachment.Filename,
});
// Share the file
if (await Sharing.isAvailableAsync()) {
await Sharing.shareAsync(fileUri, {
mimeType: 'application/octet-stream',
dialogTitle: attachment.Filename,
});
} else {
Alert.alert('Download Complete', `File saved to: ${fileUri}`);
}
// Optional cleanup
await FileSystem.deleteAsync(tempFile);
} catch (error) {
console.error('Error downloading attachment:', error);
Alert.alert('Error', 'Failed to download attachment');
}
};
/**
* Load the attachments.
*/
@@ -87,7 +84,6 @@ export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ credential
useEffect((): (() => void) => {
loadAttachments();
// Add listener for credential changes to reload attachments
const credentialChangedSub = emitter.addListener('credentialChanged', async (changedId: string) => {
if (changedId === credential.Id) {
await loadAttachments();
@@ -163,4 +159,4 @@ export const AttachmentSection: React.FC<AttachmentSectionProps> = ({ credential
))}
</ThemedView>
);
};
};

View File

@@ -1,5 +1,6 @@
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { View, StyleSheet, TouchableOpacity, Alert } from 'react-native';
@@ -34,7 +35,7 @@ export const AttachmentUploader: React.FC<AttachmentUploaderProps> = ({
try {
const result = await DocumentPicker.getDocumentAsync({
multiple: true,
copyToCacheDirectory: false,
copyToCacheDirectory: true,
});
if (result.canceled) {
@@ -45,22 +46,59 @@ export const AttachmentUploader: React.FC<AttachmentUploaderProps> = ({
for (const file of result.assets) {
if (file.uri) {
// Read file as bytes
const response = await fetch(file.uri);
const arrayBuffer = await response.arrayBuffer();
const byteArray = new Uint8Array(arrayBuffer);
try {
let fileUri = file.uri;
const attachment: Attachment = {
Id: crypto.randomUUID(),
Filename: file.name,
Blob: byteArray,
CredentialId: '', // Will be set when saving credential
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString(),
IsDeleted: false,
};
/*
* If the URI is a content:// URI and copyToCacheDirectory didn't work,
* try to copy it to a readable location
*/
if (fileUri.startsWith('content://')) {
const tempUri = `${FileSystem.cacheDirectory}${file.name}`;
await FileSystem.copyAsync({
from: fileUri,
to: tempUri,
});
fileUri = tempUri;
}
newAttachments.push(attachment);
// Read file as base64 string using FileSystem
const base64Data = await FileSystem.readAsStringAsync(fileUri, {
encoding: FileSystem.EncodingType.Base64,
});
// Convert base64 to Uint8Array
const binaryString = atob(base64Data);
const byteArray = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
byteArray[i] = binaryString.charCodeAt(i);
}
const attachment: Attachment = {
Id: crypto.randomUUID(),
Filename: file.name,
Blob: byteArray,
CredentialId: '', // Will be set when saving credential
CreatedAt: new Date().toISOString(),
UpdatedAt: new Date().toISOString(),
IsDeleted: false,
};
newAttachments.push(attachment);
// Clean up temporary file if we created one
if (fileUri !== file.uri) {
try {
await FileSystem.deleteAsync(fileUri);
} catch (cleanupError) {
console.warn('Failed to cleanup temporary file:', cleanupError);
}
}
} catch (fileError) {
console.error('Error reading file:', fileError);
setStatusMessage(`Error reading file ${file.name}.`);
setTimeout(() => setStatusMessage(''), 3000);
}
}
}

View File

@@ -222,7 +222,7 @@ PODS:
- ExpoModulesCore
- ExpoClipboard (7.0.1):
- ExpoModulesCore
- ExpoDocumentPicker (13.1.6):
- ExpoDocumentPicker (13.0.3):
- ExpoModulesCore
- ExpoFileSystem (18.0.12):
- ExpoModulesCore
@@ -2533,7 +2533,7 @@ SPEC CHECKSUMS:
ExpoAsset: 48386d40d53a8c1738929b3ed509bcad595b5516
ExpoBlur: 392c1207f71d0ecf22371621c1fbd44ba84d9742
ExpoClipboard: 44fd1c8959ee8f6175d059dc011b154c9709a969
ExpoDocumentPicker: b263a279685b6640b8c8bc70d71c83067aeaae55
ExpoDocumentPicker: 6d3d499cf15b692688a804f42927d0f35de5ebaa
ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655
ExpoFont: f354e926f8feae5e831ec8087f36652b44a0b188
ExpoHaptics: 8d199b2f33245ea85289ff6c954c7ee7c00a5b5d

View File

@@ -19,7 +19,7 @@
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.8",
"expo-dev-client": "~5.0.19",
"expo-document-picker": "^13.1.6",
"expo-document-picker": "~13.0.3",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",
@@ -11150,9 +11150,9 @@
}
},
"node_modules/expo-document-picker": {
"version": "13.1.6",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-13.1.6.tgz",
"integrity": "sha512-8FTQPDOkyCvFN/i4xyqzH7ELW4AsB6B3XBZQjn1FEdqpozo6rpNJRr7sWFU/93WrLgA9FJEKpKbyr6XxczK6BA==",
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-13.0.3.tgz",
"integrity": "sha512-348xcsiA/YhgWm1SuJNNdb5cUDpRJYCyIk8MhOU2MEDxbVRR+Q1TiUBTCIMVqaWHcxsFQzP56Wwv9n24qjeILg==",
"license": "MIT",
"peerDependencies": {
"expo": "*"

View File

@@ -40,7 +40,7 @@
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.8",
"expo-dev-client": "~5.0.19",
"expo-document-picker": "^13.1.6",
"expo-document-picker": "~13.0.3",
"expo-file-system": "~18.0.12",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.1",

View File

@@ -978,21 +978,7 @@ class SqliteClient {
for (const attachment of attachments) {
const isExistingAttachment = originalAttachmentIds.includes(attachment.Id);
if (isExistingAttachment) {
// Update existing attachment
const updateQuery = `
UPDATE Attachments
SET Filename = ?,
Blob = ?,
UpdatedAt = ?
WHERE Id = ?`;
await this.executeUpdate(updateQuery, [
attachment.Filename,
attachment.Blob as Uint8Array,
currentDateTime,
attachment.Id
]);
} else {
if (!isExistingAttachment) {
// Insert new attachment
const insertQuery = `
INSERT INTO Attachments (Id, Filename, Blob, CredentialId, CreatedAt, UpdatedAt, IsDeleted)