diff --git a/formulus/android/app/build.gradle b/formulus/android/app/build.gradle index e9be3b655..3fe030f21 100644 --- a/formulus/android/app/build.gradle +++ b/formulus/android/app/build.gradle @@ -99,8 +99,8 @@ android { applicationId = "org.opendataensemble.formulus" minSdk = rootProject.ext.minSdkVersion targetSdk = rootProject.ext.targetSdkVersion - versionCode = 76 - versionName = "1.3.3" + versionCode = 80 + versionName = "1.3.4" buildConfigField "boolean", "IS_NEW_ARCHITECTURE_ENABLED", (findProperty("newArchEnabled") ?: "false").toString() diff --git a/formulus/android/app/src/main/AndroidManifest.xml b/formulus/android/app/src/main/AndroidManifest.xml index f8149c0d5..71a612111 100644 --- a/formulus/android/app/src/main/AndroidManifest.xml +++ b/formulus/android/app/src/main/AndroidManifest.xml @@ -5,14 +5,7 @@ - - - - - - - - + diff --git a/formulus/android/build.gradle b/formulus/android/build.gradle index 2acfdd10f..2fabd5dc0 100644 --- a/formulus/android/build.gradle +++ b/formulus/android/build.gradle @@ -4,7 +4,7 @@ buildscript { minSdkVersion = 24 compileSdkVersion = 36 targetSdkVersion = 36 - ndkVersion = "27.1.12297006" + ndkVersion = "28.0.13004108" kotlinVersion = "2.1.20" } repositories { diff --git a/formulus/ios/Formulus.xcodeproj/project.pbxproj b/formulus/ios/Formulus.xcodeproj/project.pbxproj index a9063f4d1..8c151eda9 100644 --- a/formulus/ios/Formulus.xcodeproj/project.pbxproj +++ b/formulus/ios/Formulus.xcodeproj/project.pbxproj @@ -310,7 +310,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_TEAM = 57WY3GA5K7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Formulus/Info.plist; @@ -321,7 +321,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -347,7 +347,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 76; + CURRENT_PROJECT_VERSION = 80; DEVELOPMENT_TEAM = 57WY3GA5K7; INFOPLIST_FILE = Formulus/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Formulus; @@ -357,7 +357,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.3; + MARKETING_VERSION = 1.3.4; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/formulus/package.json b/formulus/package.json index 3e16f7e5a..a1f6ea8f4 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -1,6 +1,6 @@ { "name": "formulus-app", - "version": "1.3.3", + "version": "1.3.4", "packageManager": "pnpm@11.22.0", "type": "module", "private": true, diff --git a/formulus/src/webview/FormulusMessageHandlers.ts b/formulus/src/webview/FormulusMessageHandlers.ts index cc67da5ee..f9192d7f6 100644 --- a/formulus/src/webview/FormulusMessageHandlers.ts +++ b/formulus/src/webview/FormulusMessageHandlers.ts @@ -23,6 +23,7 @@ import { import { pick, types, + keepLocalCopy, isErrorWithCode, errorCodes, } from '@react-native-documents/picker'; @@ -311,19 +312,6 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { return new Promise(resolve => { try { - if (!ImagePicker || !ImagePicker.launchImageLibrary) { - console.error( - 'react-native-image-picker not available or not properly linked', - ); - resolve({ - fieldId, - status: 'error', - message: - 'Image picker functionality not available. Please ensure react-native-image-picker is properly installed and linked.', - }); - return; - } - // Image picker options for react-native-image-picker const options = { mediaType: 'photo' as const, @@ -436,6 +424,109 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { } }; + const selectImageWithSystemPicker = async () => { + try { + const [result] = await pick({ + type: [types.images], + mode: 'import', + allowMultiSelection: false, + }); + + const originalName = + typeof result.name === 'string' && result.name.trim().length > 0 + ? result.name.trim() + : 'image'; + const extensionMatch = /\.([^.\\/]{1,32})$/.exec(originalName); + const subtype = result.type + ?.split('/')[1] + ?.split('+')[0] + ?.replace(/[^a-z0-9]/gi, ''); + const extension = + extensionMatch?.[1]?.toLowerCase() || + subtype?.toLowerCase() || + 'jpg'; + const imageGuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace( + /[xy]/g, + c => { + const r = Math.floor(Math.random() * 16); + const v = c === 'x' ? r : (r % 4) + 8; + return v.toString(16); + }, + ); + const filename = `${imageGuid}.${extension}`; + const attachmentsDirectory = `${RNFS.DocumentDirectoryPath}/attachments`; + const draftDirectory = `${attachmentsDirectory}/draft`; + const draftFilePath = `${draftDirectory}/${filename}`; + + // Android pick() returns a content:// URI, which RNFS cannot read + // as a plain path. keepLocalCopy converts it into a local file in + // the app cache, then we move it into app-private attachment + // storage (move = rename on the same volume, no double copy). + const [localCopy] = await keepLocalCopy({ + files: [{ uri: result.uri, fileName: filename }], + destination: 'cachesDirectory', + }); + + if (localCopy.status !== 'success') { + resolve({ + fieldId, + status: 'error', + message: + localCopy.copyError || + 'Failed to import the selected image', + }); + return; + } + + await RNFS.mkdir(attachmentsDirectory); + await RNFS.mkdir(draftDirectory); + await RNFS.moveFile(localCopy.localUri, draftFilePath); + + resolve({ + fieldId, + status: 'success', + data: { + type: 'image', + id: imageGuid, + filename, + uri: draftFilePath, + url: `file://${draftFilePath}`, + timestamp: new Date().toISOString(), + metadata: { + size: result.size || 0, + mimeType: result.type || 'image/*', + source: 'android-storage-access-framework', + originalFileName: originalName, + persistentStorage: true, + storageLocation: 'draft_attachments', + syncReady: false, + }, + }, + }); + } catch (error) { + if ( + isErrorWithCode(error) && + error.code === errorCodes.OPERATION_CANCELED + ) { + resolve({ + fieldId, + status: 'cancelled', + message: 'Image selection cancelled by user', + }); + return; + } + + resolve({ + fieldId, + status: 'error', + message: + error instanceof Error + ? error.message + : 'Failed to select an image', + }); + } + }; + // Show action sheet with camera and gallery options Alert.alert( i18n.t('media.selectImageTitle'), @@ -445,6 +536,14 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { text: i18n.t('media.camera'), onPress: () => { void (async () => { + if (!ImagePicker.launchCamera) { + resolve({ + fieldId, + status: 'error', + message: 'Camera functionality is not available.', + }); + return; + } const perm = await ensureCameraPermission(); if (perm !== RESULTS.GRANTED) { resolve({ @@ -467,10 +566,20 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { { text: i18n.t('media.gallery'), onPress: () => { - ImagePicker.launchImageLibrary( - options, - handleImagePickerResponse, - ); + if (Platform.OS === 'android') { + void selectImageWithSystemPicker(); + } else if (ImagePicker.launchImageLibrary) { + ImagePicker.launchImageLibrary( + options, + handleImagePickerResponse, + ); + } else { + resolve({ + fieldId, + status: 'error', + message: 'Image picker functionality is not available.', + }); + } }, }, { @@ -909,10 +1018,24 @@ export function createFormulusMessageHandlers(): FormulusMessageHandlers { const draftDirectory = `${attachmentsDirectory}/draft`; const draftFilePath = `${draftDirectory}/${basename}`; + const [localCopy] = await keepLocalCopy({ + files: [{ uri: result.uri, fileName: basename }], + destination: 'cachesDirectory', + }); + + if (localCopy.status !== 'success') { + return { + fieldId, + status: 'error' as const, + message: + localCopy.copyError || 'Failed to import the selected file', + }; + } + await RNFS.mkdir(attachmentsDirectory); await RNFS.mkdir(draftDirectory); - await RNFS.copyFile(result.uri, draftFilePath); + await RNFS.moveFile(localCopy.localUri, draftFilePath); const webViewUrl = `file://${draftFilePath}`;