+## Stack
+
+- React Native 0.81.5 · Expo 54 · React 19 · TypeScript 5.9
+- State management: Redux Toolkit
+- Navigation: React Navigation 6
+- Styling: tailwind-rn
+
## Requirements
-- JDK version: 11
-- SDK version: 29 or 30
+- Node version: ≥ 20
+- JDK version: 17+
+- SDK version: 34+
In case that you open the project in Android Studio:
-- NDK version: 21.1.6
-- CMake version: 3.10.2
+- NDK version: 23.1.7779620
+- CMake version: 3.22.1
## Setup
@@ -46,6 +54,16 @@ Remember to run the tailwind command during development to dynamically add and r
yarn tailwind:dev
```
+Other useful commands:
+
+```bash
+yarn check-ts # TypeScript type check (run before committing)
+yarn lint # type check + ESLint
+yarn lint:fix # lint with auto-fix
+yarn test:unit # run Jest unit tests
+yarn test:unit:watch # run Jest in watch mode
+```
+
@@ -83,19 +101,6 @@ bash <(curl -s https://raw.githubusercontent.com/corbindavenport/nexus-tools/mas
#### Dependencies
Opening the project with Android Studio will install the necessary dependencies to start the application.
-
-
-If you are using Mac OS an receiving the following error when during gradle sync
-
-
-❌
-Caused by: groovy.lang.MissingPropertyException: No such property: logger for class: org.gradle.initialization.DefaultProjectDescriptor
-
-Try opening Android Studio with the command below to ensure Android Studio is able to find Node
-
-```bash
-open -a /Applications/Android\ Studio.app
-```
@@ -115,22 +120,6 @@ yarn android
You can only run the iOS application on a Mac OS computer.
-### iOS installation
-
-```bash
-cd ios
-
-pod install
-```
-
-If your computer is using M1 Apple chipset, replace the `pod install` command with the following:
-
-```bash
-sudo arch -x86_64 gem install ffi
-
-arch -x86_64 pod install
-```
-
### Run
```bash
@@ -141,18 +130,28 @@ yarn ios
-## Known issues
-
-Current react-native-reanimated fails with Android using RN 0.64, until we upgrade RN version, a patch needs to be added manually:
-https://github.com/software-mansion/react-native-reanimated/issues/3161#issuecomment-1112285417
-
## Test
This is what you should know about project testing.
Take a look to this official article about [testing in React Native](https://reactnative.dev/docs/testing-overview).
-### E2E
+### Unit tests
+
+```bash
+yarn test:unit
+# or a single file:
+jest path/to/file.spec.ts
+```
+
+### E2E tests (Detox)
+
+```bash
+yarn test:e2e:build:ios.debug
+yarn test:e2e:test:ios.debug
+yarn test:e2e:build:android.debug
+yarn test:e2e:test:android.debug
+```
- [Getting Started | Detox](https://wix.github.io/Detox/docs/introduction/getting-started/)
- [Jest Setup Guide | Detox](https://wix.github.io/Detox/docs/guide/jest)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index a0c4c7fd8..21ec2d9f7 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -93,7 +93,7 @@ android {
applicationId 'com.internxt.cloud'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 120
+ versionCode 123
versionName "1.9.0"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 3ff205caf..f9cf8bc73 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -1,7 +1,7 @@
Internxtautomatic
- 1.8.7
+ 1.9.0containfalse
\ No newline at end of file
diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts
index ad9c6458d..f631c29ae 100644
--- a/assets/lang/strings.ts
+++ b/assets/lang/strings.ts
@@ -253,6 +253,8 @@ const translations = {
searchInThisFolder: 'Search items in this folder',
searchInAllFolders: 'Search all my files',
encrypting: 'Encrypting',
+ scanningFolder: 'Scanning folder...',
+ scanningFolderShort: 'Scanning...',
decrypting: 'Decrypting',
downloadingPercent: 'Downloading... {0}%',
decryptingPercent: 'Decrypting... {0}%',
@@ -396,6 +398,8 @@ const translations = {
folderNameLabel: 'Name',
folderNamePlaceholder: 'Folder name',
folderNameEmpty: 'Folder name cannot be empty',
+ folderCreatedSuccess: 'Folder created!',
+ folderAlreadyExists: 'A folder with this name already exists',
folderCreateError: 'Failed to create folder. Please try again.',
uploadSuccess: 'Files uploaded successfully',
uploadedTitle: 'Uploaded!',
@@ -407,6 +411,7 @@ const translations = {
errorPrep: 'Could not prepare the file for upload.',
errorFileTooLarge: 'The maximum upload size is 300 MB at a time.',
errorFileAlreadyExists: 'A file with this name already exists in this folder.',
+ errorPaymentRequired: 'Uploading empty files requires a paid plan.',
uploading: 'Uploading…',
preparing: 'Preparing…',
cancelUploadTitle: 'Cancel upload?',
@@ -726,6 +731,16 @@ const translations = {
'You have currently used 3GB of storage. To start uploading more files, please upgrade your storage plan.',
advice: 'Get a higher plan or remove files you will no longer use in order to upload or sync your files again.',
},
+ NotEnoughDeviceSpaceModal: {
+ title: 'Not enough space on your device',
+ advice:
+ 'Internxt needs free space on your device to encrypt and upload your files. Please free up some storage and try again.',
+ },
+ EmptyFileNotAllowedModal: {
+ title: 'Empty files not supported',
+ message:
+ 'Uploading empty files is only available for some plans. Please upgrade your plan to use this feature.',
+ },
ComingSoonModal: {
title: 'Coming soon!',
subtitle: 'Our fantastic devs are working on it, so stay tuned!',
@@ -795,7 +810,9 @@ const translations = {
limitPerFile: 'Max upload size per file reached',
folderUploadCompleted: 'Successfully uploaded {0} files to "{1}"',
folderUploadPartial: '{0} of {1} files uploaded ({2} failed)',
+ folderUploadPartialWithSkipped: '{0} of {1} files uploaded ({2} failed, {3} skipped)',
folderUploadCancelled: 'Folder upload cancelled',
+ emptyFileSkippedDuringFolderUpload: 'Empty files were skipped (upgrade your plan to upload them)',
},
errors: {
runtimeLogsMissing: 'The logs file is missing or empty',
@@ -1154,6 +1171,8 @@ const translations = {
searchInThisFolder: 'Buscar en esta carpeta',
searchInAllFolders: 'Buscar en todos mis archivos',
encrypting: 'Encriptando',
+ scanningFolder: 'Escaneando carpeta...',
+ scanningFolderShort: 'Escaneando...',
decrypting: 'Desencriptando',
downloadingPercent: 'Descargando... {0}%',
decryptingPercent: 'Desencriptando... {0}%',
@@ -1294,6 +1313,8 @@ const translations = {
folderNameLabel: 'Nombre',
folderNamePlaceholder: 'Nombre de carpeta',
folderNameEmpty: 'El nombre no puede estar vacío',
+ folderCreatedSuccess: '¡Carpeta creada!',
+ folderAlreadyExists: 'Ya existe una carpeta con este nombre',
folderCreateError: 'Error al crear la carpeta. Inténtalo de nuevo.',
uploadSuccess: 'Archivos subidos correctamente',
uploadedTitle: '¡Subido!',
@@ -1305,6 +1326,7 @@ const translations = {
errorPrep: 'No se pudo preparar el archivo para la subida.',
errorFileTooLarge: 'El tamaño máximo de subida es de 300 MB a la vez.',
errorFileAlreadyExists: 'Ya existe un archivo con este nombre en esta carpeta.',
+ errorPaymentRequired: 'La subida de archivos vacíos requiere un plan de pago.',
uploading: 'Subiendo…',
preparing: 'Preparando…',
cancelUploadTitle: '¿Cancelar la subida?',
@@ -1626,6 +1648,16 @@ const translations = {
advice:
'Mejora tu plan o borra los archivos que no vayas a usar para subir o sincronizar tus archivos de nuevo.',
},
+ NotEnoughDeviceSpaceModal: {
+ title: 'No hay suficiente espacio en tu dispositivo',
+ advice:
+ 'Internxt necesita espacio libre en tu dispositivo para encriptar y subir tus archivos. Libera algo de almacenamiento e inténtalo de nuevo.',
+ },
+ EmptyFileNotAllowedModal: {
+ title: 'Archivos vacíos no permitidos',
+ message:
+ 'La subida de archivos vacíos solo está disponible en algunos planes. Actualiza tu plan para usar esta función.',
+ },
ComingSoonModal: {
title: '¡Próximamente!',
subtitle: 'Nuestros fantásticos programadores están trabajando en ello, así que mantente al tanto!',
@@ -1698,7 +1730,9 @@ const translations = {
limitPerFile: 'Tamaño máximo por archivo alcanzado',
folderUploadCompleted: '{0} archivos subidos correctamente a "{1}"',
folderUploadPartial: '{0} de {1} archivos subidos ({2} fallaron)',
+ folderUploadPartialWithSkipped: '{0} de {1} archivos subidos ({2} fallaron, {3} omitidos)',
folderUploadCancelled: 'Subida de carpeta cancelada',
+ emptyFileSkippedDuringFolderUpload: 'Se han omitido archivos vacíos (actualiza tu plan para subirlos)',
},
errors: {
runtimeLogsMissing: 'El archivo no se encuentra o está vacío',
diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj
index 791b4bb04..53f06bc04 100644
--- a/ios/Internxt.xcodeproj/project.pbxproj
+++ b/ios/Internxt.xcodeproj/project.pbxproj
@@ -10,13 +10,13 @@
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
1818AB341F31CC033FFF750B /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */; };
18D7DE9587FA2B2314575ED6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */; };
- 24C09574C61FF55529DAFF8A /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BC2974BAFE19B2AA4BDF29A1 /* libPods-InternxtShareExtension.a */; };
2F8E878CDCAC7D5C07B5C670 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 37A2C3A4643426D3F429F041 /* PrivacyInfo.xcprivacy */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
+ 7677A9D0CC2D34CF895A907E /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 944651400FAC80AAD61017EE /* libPods-Internxt.a */; };
+ 7A61A118D20D0540EF70FE74 /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 546A3CDB7E733C0B5DA4C994 /* libPods-InternxtShareExtension.a */; };
7D373856C83A43879BFBE604 /* InternxtShareExtension.appex in Copy Files */ = {isa = PBXBuildFile; fileRef = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; };
8F8148045BC14A539BD1917D /* ShareExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
- DC811D94D426EAC8A114E590 /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5FA215C540BF95F26A64F2F6 /* libPods-Internxt.a */; };
DEBEAEBF2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */; };
DEBEAEC02F72E65B00A6E6D5 /* AppGroupPendingShareModule.m in Sources */ = {isa = PBXBuildFile; fileRef = DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
@@ -63,20 +63,20 @@
30C3C55FF8AE435F8A82A9BC /* InternxtShareExtension.entitlements */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; path = InternxtShareExtension.entitlements; sourceTree = ""; };
37A2C3A4643426D3F429F041 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Internxt/PrivacyInfo.xcprivacy; sourceTree = ""; };
3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = ShareExtensionViewController.swift; sourceTree = ""; };
- 5FA215C540BF95F26A64F2F6 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; };
- 60332A8E6FC972BA25BDFAC7 /* Pods-InternxtShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.release.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.release.xcconfig"; sourceTree = ""; };
- 619167A9846BFB149A4E4B07 /* Pods-Internxt.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.release.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.release.xcconfig"; sourceTree = ""; };
+ 546A3CDB7E733C0B5DA4C994 /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 6906479D930862F73C50C0FE /* Pods-InternxtShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.debug.xcconfig"; sourceTree = ""; };
7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; fileEncoding = 9; includeInIndex = 0; path = InternxtShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 82D1B734EEAF175A4C48C4AC /* Pods-Internxt.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.release.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.release.xcconfig"; sourceTree = ""; };
+ 944651400FAC80AAD61017EE /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 9616C09E46C26A3E032F9B0B /* Pods-InternxtShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.release.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.release.xcconfig"; sourceTree = ""; };
A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-InternxtShareExtension/ExpoModulesProvider.swift"; sourceTree = ""; };
+ A7EBA4C496DAF1149115FA8A /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Internxt/SplashScreen.storyboard; sourceTree = ""; };
- B85DFBD084E638185FB887AE /* Pods-InternxtShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.debug.xcconfig"; sourceTree = ""; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; };
- BC2974BAFE19B2AA4BDF29A1 /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; };
BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; };
DAE15E8DB4DF4E048E04B0E0 /* Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
DEBEAEBD2F72E65B00A6E6D5 /* AppGroupPendingShareModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupPendingShareModule.m; path = Internxt/AppGroupPendingShareModule.m; sourceTree = ""; };
DEBEAEBE2F72E65B00A6E6D5 /* AppGroupPendingShareModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupPendingShareModule.swift; path = Internxt/AppGroupPendingShareModule.swift; sourceTree = ""; };
- DEF416740AEC235EDB62E97F /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Internxt/AppDelegate.swift; sourceTree = ""; };
F11748442D0722820044C1D9 /* Internxt-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Internxt-Bridging-Header.h"; path = "Internxt/Internxt-Bridging-Header.h"; sourceTree = ""; };
@@ -87,7 +87,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- DC811D94D426EAC8A114E590 /* libPods-Internxt.a in Frameworks */,
+ 7677A9D0CC2D34CF895A907E /* libPods-Internxt.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -95,7 +95,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 24C09574C61FF55529DAFF8A /* libPods-InternxtShareExtension.a in Frameworks */,
+ 7A61A118D20D0540EF70FE74 /* libPods-InternxtShareExtension.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -122,8 +122,8 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
- 5FA215C540BF95F26A64F2F6 /* libPods-Internxt.a */,
- BC2974BAFE19B2AA4BDF29A1 /* libPods-InternxtShareExtension.a */,
+ 944651400FAC80AAD61017EE /* libPods-Internxt.a */,
+ 546A3CDB7E733C0B5DA4C994 /* libPods-InternxtShareExtension.a */,
);
name = Frameworks;
sourceTree = "";
@@ -149,10 +149,10 @@
808723478BA906DED7CCCEE0 /* Pods */ = {
isa = PBXGroup;
children = (
- DEF416740AEC235EDB62E97F /* Pods-Internxt.debug.xcconfig */,
- 619167A9846BFB149A4E4B07 /* Pods-Internxt.release.xcconfig */,
- B85DFBD084E638185FB887AE /* Pods-InternxtShareExtension.debug.xcconfig */,
- 60332A8E6FC972BA25BDFAC7 /* Pods-InternxtShareExtension.release.xcconfig */,
+ A7EBA4C496DAF1149115FA8A /* Pods-Internxt.debug.xcconfig */,
+ 82D1B734EEAF175A4C48C4AC /* Pods-Internxt.release.xcconfig */,
+ 6906479D930862F73C50C0FE /* Pods-InternxtShareExtension.debug.xcconfig */,
+ 9616C09E46C26A3E032F9B0B /* Pods-InternxtShareExtension.release.xcconfig */,
);
path = Pods;
sourceTree = "";
@@ -222,7 +222,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Internxt" */;
buildPhases = (
- AA3DED567AE440A188E26BB3 /* [CP] Check Pods Manifest.lock */,
+ 01004B7AC75BAD5D7DF0705D /* [CP] Check Pods Manifest.lock */,
C23BF8EF9D9B379F6EADAAE0 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
@@ -230,8 +230,8 @@
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
DDE0311FCC774124B044B69C /* Embed Foundation Extensions */,
EF531C08E8F54B18956A8C0E /* Copy Files */,
- 6A9D51C204F887718EC358B4 /* [CP] Embed Pods Frameworks */,
- 70E986EE3D72E31C86FF20C3 /* [CP] Copy Pods Resources */,
+ 8D5F24CE3C0E66506BCE66EA /* [CP] Embed Pods Frameworks */,
+ 712F587FF1A539C0A665036D /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -247,14 +247,14 @@
isa = PBXNativeTarget;
buildConfigurationList = DDB0A9F040BE45D9929C1B2D /* Build configuration list for PBXNativeTarget "InternxtShareExtension" */;
buildPhases = (
- BA2DCD346879282460B0B6CD /* [CP] Check Pods Manifest.lock */,
+ 31E0BC980E2795E876261F0E /* [CP] Check Pods Manifest.lock */,
90134DAC718F4FE0B3AB7B91 /* Start Packager */,
BEAE0DA94817B237B864F997 /* [Expo] Configure project */,
3495BDAE4F75461EBD6FFF8F /* Sources */,
3ABC9B4F4B9D448B9797EBD7 /* Frameworks */,
2F5766E756C84914803E7082 /* Resources */,
AA9E71431FBB401A8272B8C4 /* Bundle React Native code and images */,
- 3EF7B1EFC832DA8463E066E7 /* [CP] Copy Pods Resources */,
+ 76428B42261F4435FAF0963A /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -343,71 +343,51 @@
shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
};
- 3EF7B1EFC832DA8463E066E7 /* [CP] Copy Pods Resources */ = {
+ 01004B7AC75BAD5D7DF0705D /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
+ inputFileListPaths = (
+ );
inputPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh",
- "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/ReactNativeFs/RNFS_PrivacyInfo.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/react-native-blob-util/ReactNativeBlobUtilPrivacyInfo.bundle",
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
);
- name = "[CP] Copy Pods Resources";
outputPaths = (
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNFS_PrivacyInfo.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReactNativeBlobUtilPrivacyInfo.bundle",
+ "$(DERIVED_FILE_DIR)/Pods-Internxt-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh\"\n";
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- 6A9D51C204F887718EC358B4 /* [CP] Embed Pods Frameworks */ = {
+ 31E0BC980E2795E876261F0E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
+ inputFileListPaths = (
+ );
inputPaths = (
- "${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh",
- "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
- "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
- "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
);
- name = "[CP] Embed Pods Frameworks";
outputPaths = (
- "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
- "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
- "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
+ "$(DERIVED_FILE_DIR)/Pods-InternxtShareExtension-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh\"\n";
+ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
- 70E986EE3D72E31C86FF20C3 /* [CP] Copy Pods Resources */ = {
+ 712F587FF1A539C0A665036D /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -453,77 +433,97 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-resources.sh\"\n";
showEnvVarsInLog = 0;
};
- 90134DAC718F4FE0B3AB7B91 /* Start Packager */ = {
+ 76428B42261F4435FAF0963A /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh",
+ "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/ReactNativeFs/RNFS_PrivacyInfo.bundle",
+ "${PODS_CONFIGURATION_BUILD_DIR}/react-native-blob-util/ReactNativeBlobUtilPrivacyInfo.bundle",
);
- name = "Start Packager";
+ name = "[CP] Copy Pods Resources";
outputPaths = (
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNFS_PrivacyInfo.bundle",
+ "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReactNativeBlobUtilPrivacyInfo.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh\"\n";
+ showEnvVarsInLog = 0;
};
- AA3DED567AE440A188E26BB3 /* [CP] Check Pods Manifest.lock */ = {
+ 8D5F24CE3C0E66506BCE66EA /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
- inputFileListPaths = (
- );
inputPaths = (
- "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
- "${PODS_ROOT}/Manifest.lock",
- );
- name = "[CP] Check Pods Manifest.lock";
- outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies",
+ "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
+ name = "[CP] Embed Pods Frameworks";
outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-Internxt-checkManifestLockResult.txt",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework",
+ "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
- AA9E71431FBB401A8272B8C4 /* Bundle React Native code and images */ = {
+ 90134DAC718F4FE0B3AB7B91 /* Start Packager */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
- name = "Bundle React Native code and images";
+ name = "Start Packager";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "set -e\n NODE_BINARY=${NODE_BINARY:-node}\n \n # Source environment files\n if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\n fi\n if [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\n fi\n \n # Set project root\n export PROJECT_ROOT=\"$PROJECT_DIR\"/..\n \n # Set entry file\n export ENTRY_FILE=\"$PROJECT_ROOT/index.share.js\"\n \n # Set up Expo CLI\n if [[ -z \"$CLI_PATH\" ]]; then\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\n fi\n \n if [[ -z \"$BUNDLE_COMMAND\" ]]; then\n export BUNDLE_COMMAND=\"export:embed\"\n fi\n \n REACT_NATIVE_SCRIPTS_PATH=$(\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts'\")\n WITH_ENVIRONMENT=\"$REACT_NATIVE_SCRIPTS_PATH/xcode/with-environment.sh\"\n REACT_NATIVE_XCODE=\"$REACT_NATIVE_SCRIPTS_PATH/react-native-xcode.sh\"\n \n /bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"";
+ shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n";
};
- BA2DCD346879282460B0B6CD /* [CP] Check Pods Manifest.lock */ = {
+ AA9E71431FBB401A8272B8C4 /* Bundle React Native code and images */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
- inputFileListPaths = (
- );
inputPaths = (
- "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
- "${PODS_ROOT}/Manifest.lock",
- );
- name = "[CP] Check Pods Manifest.lock";
- outputFileListPaths = (
);
+ name = "Bundle React Native code and images";
outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-InternxtShareExtension-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
- shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
- showEnvVarsInLog = 0;
+ shellScript = "set -e\n NODE_BINARY=${NODE_BINARY:-node}\n \n # Source environment files\n if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\n fi\n if [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\n fi\n \n # Set project root\n export PROJECT_ROOT=\"$PROJECT_DIR\"/..\n \n # Set entry file\n export ENTRY_FILE=\"$PROJECT_ROOT/index.share.js\"\n \n # Set up Expo CLI\n if [[ -z \"$CLI_PATH\" ]]; then\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\n fi\n \n if [[ -z \"$BUNDLE_COMMAND\" ]]; then\n export BUNDLE_COMMAND=\"export:embed\"\n fi\n \n REACT_NATIVE_SCRIPTS_PATH=$(\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts'\")\n WITH_ENVIRONMENT=\"$REACT_NATIVE_SCRIPTS_PATH/xcode/with-environment.sh\"\n REACT_NATIVE_XCODE=\"$REACT_NATIVE_SCRIPTS_PATH/react-native-xcode.sh\"\n \n /bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"";
};
BEAE0DA94817B237B864F997 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
@@ -609,7 +609,7 @@
/* Begin XCBuildConfiguration section */
00BF7E5F54544AE9B1CDE9D5 /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = B85DFBD084E638185FB887AE /* Pods-InternxtShareExtension.debug.xcconfig */;
+ baseConfigurationReference = 6906479D930862F73C50C0FE /* Pods-InternxtShareExtension.debug.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = InternxtShareExtension/InternxtShareExtension.entitlements;
@@ -638,7 +638,7 @@
};
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = DEF416740AEC235EDB62E97F /* Pods-Internxt.debug.xcconfig */;
+ baseConfigurationReference = A7EBA4C496DAF1149115FA8A /* Pods-Internxt.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
@@ -677,7 +677,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 619167A9846BFB149A4E4B07 /* Pods-Internxt.release.xcconfig */;
+ baseConfigurationReference = 82D1B734EEAF175A4C48C4AC /* Pods-Internxt.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
@@ -828,7 +828,7 @@
};
B247FB85ACF44D11B7509F34 /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 60332A8E6FC972BA25BDFAC7 /* Pods-InternxtShareExtension.release.xcconfig */;
+ baseConfigurationReference = 9616C09E46C26A3E032F9B0B /* Pods-InternxtShareExtension.release.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = InternxtShareExtension/InternxtShareExtension.entitlements;
diff --git a/ios/Internxt/AppDelegate.swift b/ios/Internxt/AppDelegate.swift
index 0956651af..574040176 100644
--- a/ios/Internxt/AppDelegate.swift
+++ b/ios/Internxt/AppDelegate.swift
@@ -64,6 +64,12 @@ public class AppDelegate: ExpoAppDelegate {
deleteFromSharedKeychain(key: "shared_bridgeUser", accessGroup: sharedGroup)
deleteFromSharedKeychain(key: "shared_userId", accessGroup: sharedGroup)
}
+
+ if privateKeychainItemExists(key: "themePreference") {
+ copyToSharedKeychain(privateKey: "themePreference", sharedKey: "shared_themePreference", accessGroup: sharedGroup)
+ } else {
+ deleteFromSharedKeychain(key: "shared_themePreference", accessGroup: sharedGroup)
+ }
}
private func privateKeychainItemExists(key: String) -> Bool {
diff --git a/ios/InternxtShareExtension/Info.plist b/ios/InternxtShareExtension/Info.plist
index 14b0f9560..66f74d953 100644
--- a/ios/InternxtShareExtension/Info.plist
+++ b/ios/InternxtShareExtension/Info.plist
@@ -65,13 +65,13 @@
ShareExtensionBackgroundColoralpha
- 1
+ 0blue
- 255
+ 0green
- 255
+ 0red
- 255
+ 0UIAppFonts
diff --git a/ios/InternxtShareExtension/ShareExtensionViewController.swift b/ios/InternxtShareExtension/ShareExtensionViewController.swift
index 182f8e6f2..3d3e77188 100644
--- a/ios/InternxtShareExtension/ShareExtensionViewController.swift
+++ b/ios/InternxtShareExtension/ShareExtensionViewController.swift
@@ -112,12 +112,13 @@ class ShareExtensionViewController: UIViewController {
var initialProps = sharedData ?? [:]
// ── Internxt: inject auth state from Keychain ───────────────────────────
if let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String {
- initialProps["photosToken"] = readFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup)
- initialProps["mnemonic"] = readFromSharedKeychainStripped(key: "shared_mnemonic", accessGroup: sharedGroup)
- initialProps["rootFolderId"] = readFromSharedKeychainStripped(key: "shared_rootFolderId", accessGroup: sharedGroup)
- initialProps["bucket"] = readFromSharedKeychainStripped(key: "shared_bucket", accessGroup: sharedGroup)
- initialProps["bridgeUser"] = readFromSharedKeychainStripped(key: "shared_bridgeUser", accessGroup: sharedGroup)
- initialProps["userId"] = readFromSharedKeychainStripped(key: "shared_userId", accessGroup: sharedGroup)
+ initialProps["photosToken"] = readFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup)
+ initialProps["mnemonic"] = readFromSharedKeychainStripped(key: "shared_mnemonic", accessGroup: sharedGroup)
+ initialProps["rootFolderId"] = readFromSharedKeychainStripped(key: "shared_rootFolderId", accessGroup: sharedGroup)
+ initialProps["bucket"] = readFromSharedKeychainStripped(key: "shared_bucket", accessGroup: sharedGroup)
+ initialProps["bridgeUser"] = readFromSharedKeychainStripped(key: "shared_bridgeUser", accessGroup: sharedGroup)
+ initialProps["userId"] = readFromSharedKeychainStripped(key: "shared_userId", accessGroup: sharedGroup)
+ initialProps["themePreference"] = readFromSharedKeychainStripped(key: "shared_themePreference", accessGroup: sharedGroup)
}
// ── From expo-share-extension library ──────────────────────────────────
let currentBounds = self.view.bounds
diff --git a/ios/Podfile b/ios/Podfile
index bd8d78720..499dc6a4d 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -59,7 +59,7 @@ target 'Internxt' do
:mac_catalyst_enabled => false,
:ccache_enabled => ccache_enabled?(podfile_properties),
)
-# @generated begin post-install-build-settings - expo prebuild (DO NOT MODIFY) sync-f4e2a1acf7da60b403fcf5c1d5d9517cb9953853
+ # @generated begin post-install-build-settings - expo prebuild (DO NOT MODIFY) sync-f4e2a1acf7da60b403fcf5c1d5d9517cb9953853
installer.pods_project.targets.each do |target|
unless target.name == 'Sentry'
target.build_configurations.each do |config|
@@ -67,6 +67,29 @@ target 'Internxt' do
end
end
end
+
+ # Fix: fmt/glog consteval incompatibility with Xcode 26 / Apple Clang 17.
+ # consteval in C++20 is strictly enforced; forcing c++17 disables it entirely
+ # in fmt (FMT_CONSTEVAL becomes a no-op when __cpp_consteval is not defined).
+ installer.pods_project.targets.each do |target|
+ if ['fmt', 'glog'].include?(target.name)
+ target.build_configurations.each do |config|
+ config.build_settings['CLANG_CXX_LANGUAGE_STANDARD'] = 'c++17'
+ config.build_settings['OTHER_CPLUSPLUSFLAGS'] = '$(inherited) -DFMT_USE_CONSTEVAL=0'
+ end
+ end
+ end
+
+ # This is necessary for Xcode 14, because it signs resource bundles by default
+ # when building for devices.
+ installer.target_installation_results.pod_target_installation_results
+ .each do |pod_name, target_installation_result|
+ target_installation_result.resource_bundle_targets.each do |resource_bundle_target|
+ resource_bundle_target.build_configurations.each do |config|
+ config.build_settings['CODE_SIGNING_ALLOWED'] = 'NO'
+ end
+ end
+ end
# @generated end post-install-build-settings
end
end
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 08a6abc2f..68482c32c 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2969,6 +2969,6 @@ SPEC CHECKSUMS:
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
-PODFILE CHECKSUM: 1c3110710c3007a8b81ff873b578769e00b42ac1
+PODFILE CHECKSUM: 4181b7b17d76289deba649e281254c0cc71c420e
COCOAPODS: 1.16.2
diff --git a/jest.config.ts b/jest.config.ts
index 68bb6e867..2582f8314 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -22,12 +22,14 @@ const untranspiledModulePatterns = [
'uuid',
'p-limit',
'yocto-queue',
+ 'mime',
];
const config: Config.InitialOptions = {
preset: 'jest-expo',
verbose: true,
testRegex: ['\\.spec\\.ts$', '\\.spec\\.tsx$'],
+ setupFiles: ['./jest.setup.ts'],
setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect'],
transformIgnorePatterns: [`node_modules/(?!${untranspiledModulePatterns.join('|')})`],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
diff --git a/jest.setup.ts b/jest.setup.ts
new file mode 100644
index 000000000..e38a818da
--- /dev/null
+++ b/jest.setup.ts
@@ -0,0 +1,5 @@
+// axios 1.x auto-detects fetch and uses it as adapter, but expo/virtual/streams
+// has a broken ReadableStream.cancel in the Jest environment. Remove fetch so
+// axios falls back to the http adapter before any module loads it.
+delete (globalThis as any).fetch;
+delete (globalThis as any).ReadableStream;
diff --git a/package.json b/package.json
index 1a79f7e95..cbd6df765 100644
--- a/package.json
+++ b/package.json
@@ -49,7 +49,7 @@
"@types/luxon": "^3.0.1",
"@types/unorm": "^1.3.28",
"async": "^3.2.6",
- "axios": "^1.13.5",
+ "axios": "^1.15.0",
"base-64": "^1.0.0",
"crypto-js": "=3.1.9-1",
"dayjs": "^1.11.19",
@@ -83,7 +83,7 @@
"intl": "^1.2.5",
"jail-monkey": "^2.8.3",
"jwt-decode": "^4.0.0",
- "lodash": "^4.17.23",
+ "lodash": "^4.18.1",
"luxon": "^3.7.2",
"p-limit": "^7.2.0",
"phosphor-react-native": "3.0.3",
diff --git a/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx b/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
index 8d24b9a1c..55bd6a766 100644
--- a/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
+++ b/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
@@ -1,4 +1,5 @@
import { time } from '@internxt-mobile/services/common/time';
+import strings from '../../../../../../assets/lang/strings';
import { driveFileService } from '@internxt-mobile/services/drive/file';
import { items } from '@internxt/lib';
import { ArrowCircleUpIcon, XCircleIcon } from 'phosphor-react-native';
@@ -29,6 +30,7 @@ function DriveGridModeItemComp(props: DriveItemProps): JSX.Element {
const isUploading = props.status === DriveItemStatus.Uploading;
const isDownloading = props.status === DriveItemStatus.Downloading;
const isFolderUploading = isUploading && isFolder && !!props.folderUploadProgress;
+ const isFolderScanning = isUploading && isFolder && !props.folderUploadProgress;
const maxThumbnailHeight = 96;
const getThumbnailWidth = () => {
@@ -96,6 +98,68 @@ function DriveGridModeItemComp(props: DriveItemProps): JSX.Element {
);
};
+ const renderUploadOverlay = () => {
+ if (!isUploading) return null;
+
+ if (isFolderScanning) {
+ return (
+
+
+
+ {strings.screens.drive.scanningFolderShort}
+
+
+
+
+ );
+ }
+
+ if (isFolderUploading) {
+ return (
+
+
+
+
+ {props.folderUploadProgress?.totalFiles
+ ? `${props.folderUploadProgress.uploadedFiles}/${props.folderUploadProgress.totalFiles}`
+ : '...'}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {((props.progress || 0) * 100).toFixed(0) + '%'}
+
+
+
+ );
+ };
+
return (
- {isUploading &&
- (isFolderUploading ? (
-
-
-
-
- {props.folderUploadProgress?.totalFiles
- ? `${props.folderUploadProgress.uploadedFiles}/${props.folderUploadProgress.totalFiles}`
- : '...'}
-
-
-
-
- ) : (
-
-
-
-
- {((props.progress || 0) * 100).toFixed(0) + '%'}
-
-
-
- ))}
+ {renderUploadOverlay()}
diff --git a/src/components/drive/lists/items/DriveListModeItem/DriveListModeItem.tsx b/src/components/drive/lists/items/DriveListModeItem/DriveListModeItem.tsx
index b6fae4ee8..ac9f2b794 100644
--- a/src/components/drive/lists/items/DriveListModeItem/DriveListModeItem.tsx
+++ b/src/components/drive/lists/items/DriveListModeItem/DriveListModeItem.tsx
@@ -26,6 +26,7 @@ export function DriveListModeItem(props: DriveItemProps): JSX.Element {
const isUploading = props.status === DriveItemStatus.Uploading;
const isDownloading = props.status === DriveItemStatus.Downloading;
const isFolderUploading = isUploading && isFolder && !!props.folderUploadProgress;
+ const isFolderScanning = isUploading && isFolder && !props.folderUploadProgress;
const getUpdatedAt = () => {
if (props.data.createdAt) {
@@ -38,6 +39,13 @@ export function DriveListModeItem(props: DriveItemProps): JSX.Element {
const progress = props.progress;
const renderUploadProgress = () => {
+ if (isFolderScanning) {
+ return (
+
+ {strings.screens.drive.scanningFolder}
+
+ );
+ }
if (isFolderUploading) {
return (
@@ -76,7 +84,7 @@ export function DriveListModeItem(props: DriveItemProps): JSX.Element {
const renderOptionsButton = () => {
if (props.hideOptionsButton) return null;
- if (isFolderUploading) {
+ if (isFolderUploading || isFolderScanning) {
return (
{};
+const uploadFolderFileIOS = async (
+ fileNode: FolderTreeNode,
+ parentUuid: string,
+ signal: AbortSignal,
+ uploadAndCreateFileEntry: UploadFileEntryFn,
+ onEmptyFileSkipped: () => void,
+): Promise => {
+ const filePath = fileNode.uri.replace('file://', '');
+ const { extension, plainName } = getFileExtensionAndPlainName(fileNode.name);
+ try {
+ await uploadAndCreateFileEntry(filePath, plainName, extension, parentUuid, noopProgress, undefined, undefined, signal);
+ } catch (err) {
+ if (err instanceof EmptyFileNotAllowedError) onEmptyFileSkipped();
+ throw err;
+ }
+};
+
+const uploadFolderFileAndroid = async (
+ fileNode: FolderTreeNode,
+ parentUuid: string,
+ signal: AbortSignal,
+ uploadId: string,
+ uploadAndCreateFileEntry: UploadFileEntryFn,
+ onEmptyFileSkipped: () => void,
+): Promise => {
+ const { extension, plainName } = getFileExtensionAndPlainName(fileNode.name);
+ const tempPath = fileSystemService.tmpFilePath(`${uploadId}_${fileNode.name}`);
+ const tempUri = fileSystemService.pathToUri(tempPath);
+ try {
+ await StorageAccessFramework.copyAsync({ from: fileNode.uri, to: tempUri });
+ if (signal.aborted) return;
+ await uploadAndCreateFileEntry(tempPath, plainName, extension, parentUuid, noopProgress, undefined, undefined, signal);
+ } catch (err) {
+ if (err instanceof EmptyFileNotAllowedError) onEmptyFileSkipped();
+ throw err;
+ } finally {
+ await fileSystemService.unlinkIfExists(tempPath).catch((e) => {
+ logger.warn('[useFolderUpload] Failed to unlink temp file: ' + (e as Error).message);
+ });
+ }
+};
+
const showFolderUploadResult = (
- result: { cancelled: boolean; failedFiles: number; uploadedFiles: number; totalFiles: number },
+ result: { cancelled: boolean; failedFiles: number; uploadedFiles: number; totalFiles: number; skippedFiles: number },
folderName: string,
) => {
if (result.cancelled) {
notificationsService.show({ type: NotificationType.Info, text1: strings.messages.folderUploadCancelled });
- } else if (result.failedFiles === 0) {
+ } else if (result.failedFiles === 0 && result.skippedFiles === 0) {
notificationsService.show({
type: NotificationType.Success,
text1: strings.formatString(strings.messages.folderUploadCompleted, result.uploadedFiles, folderName) as string,
@@ -47,10 +91,11 @@ const showFolderUploadResult = (
notificationsService.show({
type: NotificationType.Warning,
text1: strings.formatString(
- strings.messages.folderUploadPartial,
+ strings.messages.folderUploadPartialWithSkipped,
result.uploadedFiles,
result.totalFiles,
result.failedFiles,
+ result.skippedFiles,
) as string,
});
}
@@ -70,6 +115,7 @@ type UploadFileEntryFn = (
progressCallback: ProgressCallback,
modificationTime?: string,
creationTime?: string,
+ signal?: AbortSignal,
) => Promise;
export interface FolderUploadCollisionModalState {
@@ -83,6 +129,20 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
const dispatch = useAppDispatch();
const { focusedFolder, loadFolderContent } = useDrive();
const folderUploads = useAppSelector((state) => state.drive.folderUploads);
+ const { limit } = useAppSelector((state) => state.storage);
+ const usage = useAppSelector(storageSelectors.usage);
+
+ const hasShownEmptyFileNoticeRef = useRef(false);
+
+ const handleEmptyFileSkipped = () => {
+ if (!hasShownEmptyFileNoticeRef.current) {
+ hasShownEmptyFileNoticeRef.current = true;
+ notificationsService.show({
+ type: NotificationType.Warning,
+ text1: strings.messages.emptyFileSkippedDuringFolderUpload,
+ });
+ }
+ };
const collisionResolverRef = useRef<((action: NameCollisionAction | null) => void) | null>(null);
const [collisionState, setCollisionState] = useState<{
@@ -115,37 +175,25 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
collisionResolverRef.current?.(null);
};
+ const resolveCollision = async (pickedName: string, parentUuid: string): Promise => {
+ const { existentFolders } = await driveFolderService.checkDuplicatedFolders(parentUuid, [pickedName]);
+ if (existentFolders.length === 0) return pickedName;
+ const existing = existentFolders[0];
+ const action = await waitForCollisionResolution(pickedName, existing.uuid, existing.id);
+ if (action === null) return null;
+ if (action === 'replace') {
+ await driveTrashService.moveToTrash([{ uuid: existing.uuid, id: existing.id, type: 'folder' }]);
+ return pickedName;
+ }
+ return getUniqueFolderName(pickedName, parentUuid);
+ };
+
const handleUploadFolder = async () => {
dispatch(uiActions.setShowUploadFileModal(false));
-
- const uploadFolderFileIOS = async (fileNode: FolderTreeNode, parentUuid: string): Promise => {
- const filePath = fileNode.uri.replace('file://', '');
- const { extension, plainName } = getFileExtensionAndPlainName(fileNode.name);
- await uploadAndCreateFileEntry(filePath, plainName, extension, parentUuid, noopProgress);
- };
-
- const uploadFolderFileAndroid = async (
- fileNode: FolderTreeNode,
- parentUuid: string,
- signal: AbortSignal,
- uploadId: string,
- ): Promise => {
- const { extension, plainName } = getFileExtensionAndPlainName(fileNode.name);
- // Prefix with uploadId to isolate temp files across concurrent uploads
- const tempPath = fileSystemService.tmpFilePath(`${uploadId}_${fileNode.name}`);
- const tempUri = fileSystemService.pathToUri(tempPath);
- try {
- await StorageAccessFramework.copyAsync({ from: fileNode.uri, to: tempUri });
- if (signal.aborted) return;
- await uploadAndCreateFileEntry(tempPath, plainName, extension, parentUuid, noopProgress);
- } finally {
- await fileSystemService.unlinkIfExists(tempPath).catch((e) => {
- logger.warn('[useFolderUpload] Failed to unlink temp file: ' + (e as Error).message);
- });
- }
- };
+ hasShownEmptyFileNoticeRef.current = false;
const uploadId = uuid.v4().toString();
+ const abortController = new AbortController();
try {
// 1. Pick folder
@@ -153,14 +201,38 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
if (!picked) return;
logger.info(`[useFolderUpload][${uploadId}] Folder: "${picked.name}" (${picked.uri})`);
+ const startedAt = Date.now();
+ dispatch(
+ driveActions.addFolderUpload({
+ uploadId,
+ folderName: picked.name,
+ totalFiles: 0,
+ uploadedFiles: 0,
+ failedFiles: 0,
+ status: 'scanning',
+ startedAt,
+ }),
+ );
+ folderUploadCancellationService.register(uploadId, abortController);
+
// 2. Traverse
- const tree = await folderTraversalService.traverseFolder(picked.uri);
+ const tree = await folderTraversalService.traverseFolder(picked.uri, abortController.signal);
logger.info(`[useFolderUpload][${uploadId}] Tree: ${tree.files.length} files, ${tree.dirs.length} dirs`);
- // 3. Storage quota check
+ // 3. Device space check
+ if (tree.files.length > 0) {
+ const maxFileSize = Math.max(...tree.files.map((f) => f.size));
+ const hasDeviceSpace = await fileSystemService.checkAvailableStorage(maxFileSize).catch(() => true);
+ if (!hasDeviceSpace) {
+ dispatch(uiActions.setShowNotEnoughDeviceSpaceModal(true));
+ return;
+ }
+ }
+
+ // 4. Cloud quota check
const totalSize = tree.files.reduce((sum, file) => sum + file.size, 0);
- const hasStorage = await fileSystemService.checkAvailableStorage(totalSize).catch(() => true);
- if (!hasStorage) {
+ const isUnlimited = limit >= INFINITE_PLAN;
+ if (!isUnlimited && usage + totalSize > limit) {
dispatch(uiActions.setShowRunOutSpaceModal(true));
return;
}
@@ -169,38 +241,22 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
throw new Error('No focused folder UUID');
}
- // Name collision check
- const { existentFolders } = await driveFolderService.checkDuplicatedFolders(focusedFolder.uuid, [picked.name]);
- if (existentFolders.length > 0) {
- const existing = existentFolders[0];
- const action = await waitForCollisionResolution(picked.name, existing.uuid, existing.id);
- if (action === null) return;
- if (action === 'replace') {
- await driveTrashService.moveToTrash([{ uuid: existing.uuid, id: existing.id, type: 'folder' }]);
- } else {
- picked.name = await getUniqueFolderName(picked.name, focusedFolder.uuid);
- }
- }
+ // 5. Name collision check
+ const resolvedName = await resolveCollision(picked.name, focusedFolder.uuid);
+ if (resolvedName === null) return;
+ picked.name = resolvedName;
- // 4. Create the root folder (merge if already exists)
+ // 5. Create the root folder (merge if already exists)
const rootFolderUuid = await createFolderWithMerge(focusedFolder.uuid, picked.name);
logger.info(`[useFolderUpload][${uploadId}] Root folder "${picked.name}" - ${rootFolderUuid}`);
- // 5. Initialize progress state + AbortController
- const startedAt = Date.now();
dispatch(
- driveActions.addFolderUpload({
+ driveActions.updateFolderUpload({
uploadId,
- folderName: picked.name,
totalFiles: tree.files.length,
- uploadedFiles: 0,
- failedFiles: 0,
status: 'uploading',
- startedAt,
}),
);
- const abortController = new AbortController();
- folderUploadCancellationService.register(uploadId, abortController);
// 6. Log upload start
analytics.track(DriveAnalyticsEvent.FolderUploadStarted, {
@@ -229,9 +285,9 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
},
uploadFile: async (fileNode, parentUuid, signal) => {
if (Platform.OS === 'android' && fileNode.uri.startsWith('content://')) {
- await uploadFolderFileAndroid(fileNode, parentUuid, signal, uploadId);
+ await uploadFolderFileAndroid(fileNode, parentUuid, signal, uploadId, uploadAndCreateFileEntry, handleEmptyFileSkipped);
} else {
- await uploadFolderFileIOS(fileNode, parentUuid);
+ await uploadFolderFileIOS(fileNode, parentUuid, signal, uploadAndCreateFileEntry, handleEmptyFileSkipped);
}
},
});
@@ -253,6 +309,10 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
// 9. Display result
showFolderUploadResult(result, picked.name);
} catch (err) {
+ if (abortController.signal.aborted) {
+ notificationsService.show({ type: NotificationType.Info, text1: strings.messages.folderUploadCancelled });
+ return;
+ }
const error = err as Error;
if (!(err instanceof FolderTooLargeError)) {
errorService.reportError(error);
diff --git a/src/components/modals/AddModal/index.tsx b/src/components/modals/AddModal/index.tsx
index 4d63e89ad..1dd2a39e9 100644
--- a/src/components/modals/AddModal/index.tsx
+++ b/src/components/modals/AddModal/index.tsx
@@ -21,6 +21,7 @@ import { Alert, PermissionsAndroid, Platform, TouchableHighlight, View } from 'r
import { useDrive } from '@internxt-mobile/hooks/drive';
import { imageService, logger } from '@internxt-mobile/services/common';
import { uploadService } from '@internxt-mobile/services/common/network/upload/upload.service';
+import { EmptyFileNotAllowedError, isEmptyFilePlanError } from '@internxt-mobile/services/drive/file/utils/emptyFileErrors';
import drive from '@internxt-mobile/services/drive';
import {
generateFileName,
@@ -184,6 +185,7 @@ function AddModal(): JSX.Element {
progressCallback: ProgressCallback,
modificationTime?: string,
creationTime?: string,
+ signal?: AbortSignal,
) {
if (!user) {
throw new Error('User not found in Redux state');
@@ -195,6 +197,33 @@ function AddModal(): JSX.Element {
checkFileSizeLimitToUpload(fileStat.size, fileName);
const fileSize = fileStat.size;
+
+ const modTimestamp = modificationTime ?? fileStat.mtime;
+ const modificationTimeISO = modTimestamp ? new Date(modTimestamp).toISOString() : undefined;
+ const createTimestamp = creationTime ?? fileStat.ctime;
+ const creationTimeISO = createTimestamp ? new Date(createTimestamp).toISOString() : undefined;
+
+ if (fileSize === 0) {
+ const emptyEntry: FileEntryByUuid = {
+ type: fileExtension,
+ size: 0,
+ plainName: fileName,
+ bucket,
+ folderUuid: currentFolderId,
+ encryptVersion: EncryptionVersion.Aes03,
+ modificationTime: modificationTimeISO,
+ creationTime: creationTimeISO,
+ };
+ try {
+ const entry = await uploadService.createFileEntry(emptyEntry);
+ drive.events.emit({ event: DriveEventKey.UploadCompleted });
+ return { ...entry, thumbnails: [] } as DriveFileData;
+ } catch (err) {
+ if (isEmptyFilePlanError(err)) throw new EmptyFileNotAllowedError();
+ throw err;
+ }
+ }
+
const fileId = await network.uploadFile(
filePath,
bucket,
@@ -206,6 +235,7 @@ function AddModal(): JSX.Element {
},
{
notifyProgress: progressCallback,
+ signal,
},
);
logger.info('File uploaded with fileId: ', fileId);
@@ -215,11 +245,6 @@ function AddModal(): JSX.Element {
const folderId = currentFolderId;
const plainName = fileName;
- const modTimestamp = modificationTime ?? fileStat.mtime;
- const modificationTimeISO = modTimestamp ? new Date(modTimestamp).toISOString() : undefined;
-
- const createTimestamp = creationTime ?? fileStat.ctime;
- const creationTimeISO = createTimestamp ? new Date(createTimestamp).toISOString() : undefined;
const fileEntryByUuid: FileEntryByUuid = {
fileId: fileId,
diff --git a/src/components/modals/EmptyFileNotAllowedModal/index.tsx b/src/components/modals/EmptyFileNotAllowedModal/index.tsx
new file mode 100644
index 000000000..31b5a347b
--- /dev/null
+++ b/src/components/modals/EmptyFileNotAllowedModal/index.tsx
@@ -0,0 +1,44 @@
+import strings from 'assets/lang/strings';
+import { View } from 'react-native';
+import AppButton from 'src/components/AppButton';
+import AppText from 'src/components/AppText';
+import useGetColor from 'src/hooks/useColor';
+import { useAppDispatch, useAppSelector } from 'src/store/hooks';
+import { uiActions } from 'src/store/slices/ui';
+import { useTailwind } from 'tailwind-rn';
+import CenterModal from '../CenterModal';
+
+function EmptyFileNotAllowedModal(): JSX.Element {
+ const tailwind = useTailwind();
+ const getColor = useGetColor();
+ const dispatch = useAppDispatch();
+ const isOpen = useAppSelector((state) => state.ui.showEmptyFileNotAllowedModal);
+
+ const handleClose = () => {
+ dispatch(uiActions.setShowEmptyFileNotAllowedModal(false));
+ };
+
+ return (
+
+
+
+ {strings.modals.EmptyFileNotAllowedModal.title}
+
+
+ {strings.modals.EmptyFileNotAllowedModal.message}
+
+
+
+
+ );
+}
+
+export default EmptyFileNotAllowedModal;
diff --git a/src/components/modals/NotEnoughDeviceSpaceModal/index.tsx b/src/components/modals/NotEnoughDeviceSpaceModal/index.tsx
new file mode 100644
index 000000000..c03117957
--- /dev/null
+++ b/src/components/modals/NotEnoughDeviceSpaceModal/index.tsx
@@ -0,0 +1,77 @@
+import { TouchableHighlight, TouchableWithoutFeedback, View } from 'react-native';
+import Modal from 'react-native-modal';
+
+import AppText from 'src/components/AppText';
+import { useTailwind } from 'tailwind-rn';
+import strings from '../../../../assets/lang/strings';
+import useGetColor from '../../../hooks/useColor';
+import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { uiActions } from '../../../store/slices/ui';
+
+const NotEnoughDeviceSpaceModal = (): JSX.Element => {
+ const tailwind = useTailwind();
+ const getColor = useGetColor();
+ const dispatch = useAppDispatch();
+
+ const isVisible = useAppSelector((state) => state.ui.showNotEnoughDeviceSpaceModal);
+
+ const handleClose = () => {
+ dispatch(uiActions.setShowNotEnoughDeviceSpaceModal(false));
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {strings.modals.NotEnoughDeviceSpaceModal.title}
+
+
+
+
+ {strings.modals.NotEnoughDeviceSpaceModal.advice}
+
+
+
+
+
+ {strings.buttons.close}
+
+
+
+
+
+
+ );
+};
+
+export default NotEnoughDeviceSpaceModal;
diff --git a/src/components/modals/RunOutOfStorageModal/index.tsx b/src/components/modals/RunOutOfStorageModal/index.tsx
index 44e88dd8a..c39e52db9 100644
--- a/src/components/modals/RunOutOfStorageModal/index.tsx
+++ b/src/components/modals/RunOutOfStorageModal/index.tsx
@@ -10,6 +10,7 @@ import { openUrl } from '../../../helpers/utils';
import useGetColor from '../../../hooks/useColor';
import { PRICING_URL } from '../../../services/drive/constants';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
+import { paymentsSelectors } from '../../../store/slices/payments';
import { uiActions } from '../../../store/slices/ui';
import { INFINITE_PLAN } from '../../../types';
@@ -20,6 +21,7 @@ function RunOutOfStorageModal(): JSX.Element {
const { limit, totalUsage } = useAppSelector((state) => state.storage);
const { showRunOutOfSpaceModal } = useAppSelector((state) => state.ui);
+ const showBilling = useAppSelector(paymentsSelectors.shouldShowBilling);
const getLimitString = () => {
if (limit === 0) {
@@ -101,15 +103,27 @@ function RunOutOfStorageModal(): JSX.Element {
-
-
- {strings.buttons.upgradeNow}
-
-
+ {showBilling ? (
+
+
+ {strings.buttons.upgradeNow}
+
+
+ ) : (
+
+
+ {strings.buttons.close}
+
+
+ )}
diff --git a/src/contexts/Theme/Theme.context.tsx b/src/contexts/Theme/Theme.context.tsx
index e280029d2..f836fbd74 100644
--- a/src/contexts/Theme/Theme.context.tsx
+++ b/src/contexts/Theme/Theme.context.tsx
@@ -1,5 +1,6 @@
import asyncStorageService from '@internxt-mobile/services/AsyncStorageService';
import { logger } from '@internxt-mobile/services/common';
+import secureStorageService from '@internxt-mobile/services/SecureStorageService';
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
import { Appearance, NativeEventSubscription } from 'react-native';
@@ -22,10 +23,16 @@ interface ThemeProviderProps {
*/
const loadThemePreference = async (): Promise => {
try {
- const savedTheme = await asyncStorageService.getThemePreference();
- logger.info(`Saved theme from storage: ${savedTheme}`);
+ // SecureStorage is the source of truth after migration, was added to share with iOS in the keychain
+ const secureTheme = await secureStorageService.getItem('themePreference');
+ if (secureTheme === 'light' || secureTheme === 'dark') {
+ logger.info(`Loaded theme from SecureStorage: ${secureTheme}`);
+ return secureTheme;
+ }
+ const savedTheme = await asyncStorageService.getThemePreference();
if (savedTheme) {
+ logger.info(`Loaded theme from AsyncStorage: ${savedTheme}`);
return savedTheme;
}
@@ -135,6 +142,7 @@ export const ThemeProvider: React.FC = ({ children }) => {
applyTheme(newTheme, setThemeState);
await asyncStorageService.saveThemePreference(newTheme);
+ secureStorageService.setItem('themePreference', newTheme).catch(() => undefined);
setTimeout(() => {
isSettingThemeRef.current = false;
diff --git a/src/helpers/utils.ts b/src/helpers/utils.ts
index cc08bbbe1..d264790bd 100644
--- a/src/helpers/utils.ts
+++ b/src/helpers/utils.ts
@@ -1,16 +1,11 @@
import { Linking } from 'react-native';
import { logger } from '../services/common';
-export const openUrl = async (url: string) => {
+export const openUrl = async (url: string, onError?: (error: unknown) => void) => {
try {
- const supported = await Linking.canOpenURL(url);
-
- if (supported) {
- await Linking.openURL(url);
- } else {
- logger.error(`Cannot open URL: ${url}, not supported`);
- }
+ await Linking.openURL(url);
} catch (error) {
logger.error(`An error occurred trying to open the URL: ${url}`, error);
+ onError?.(error);
}
};
diff --git a/src/navigation/TabExplorerNavigator.tsx b/src/navigation/TabExplorerNavigator.tsx
index c847d551e..1b1126b56 100644
--- a/src/navigation/TabExplorerNavigator.tsx
+++ b/src/navigation/TabExplorerNavigator.tsx
@@ -7,13 +7,16 @@ import { AppState, AppStateStatus, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import SecurityModal from 'src/components/modals/SecurityModal';
import { authThunks } from 'src/store/slices/auth';
+import { checkPermissionRevocationThunk } from 'src/store/slices/photos';
import { storageThunks } from 'src/store/slices/storage';
import { useTailwind } from 'tailwind-rn';
import BottomTabNavigator from '../components/BottomTabNavigator';
import AddModal from '../components/modals/AddModal';
import DriveItemInfoModal from '../components/modals/DriveItemInfoModal';
import DriveRenameModal from '../components/modals/DriveRenameModal';
+import EmptyFileNotAllowedModal from '../components/modals/EmptyFileNotAllowedModal';
import MoveItemsModal from '../components/modals/MoveItemsModal';
+import NotEnoughDeviceSpaceModal from '../components/modals/NotEnoughDeviceSpaceModal';
import RunOutOfStorageModal from '../components/modals/RunOutOfStorageModal';
import { SharedLinkInfoModal } from '../components/modals/SharedLinkInfoModal';
import SignOutModal from '../components/modals/SignOutModal';
@@ -53,6 +56,7 @@ export default function TabExplorerNavigator(props: RootStackScreenProps<'TabExp
async function handleOnAppStateChange(state: AppStateStatus) {
if (state === 'active') {
+ dispatch(checkPermissionRevocationThunk());
try {
await dispatch(storageThunks.loadLimitThunk()).unwrap();
} catch {
@@ -64,6 +68,7 @@ export default function TabExplorerNavigator(props: RootStackScreenProps<'TabExp
}
}
}
+
return (
+
+
diff --git a/src/network/NetworkFacade.ts b/src/network/NetworkFacade.ts
index f81e7112b..2525b03d2 100644
--- a/src/network/NetworkFacade.ts
+++ b/src/network/NetworkFacade.ts
@@ -1,4 +1,5 @@
import * as RNFS from '@dr.pogodin/react-native-fs';
+import { AbortError } from './errors';
import { logger } from '@internxt-mobile/services/common';
import { decryptFile, encryptFile, encryptFileToChunks, joinFiles } from '@internxt/rn-crypto';
import { ALGORITHMS, Network } from '@internxt/sdk/dist/network';
@@ -108,6 +109,9 @@ export class NetworkFacade {
const fileSize = stat.size;
const shouldEnableEncryptionProgress = fileSize >= MINIMUM_SIZE_FOR_ENCRYPTION_PROGRESS;
+ let activeFetchTask: ReturnType | null = null;
+ let aborted = false;
+
const uploadFilePromise = uploadFile(
this.network,
this.cryptoLib,
@@ -136,7 +140,9 @@ export class NetworkFacade {
fileHash = ripemd160(Buffer.from(await RNFS.hash(encryptedFilePath, 'sha256'), 'hex')).toString('hex');
},
async (url: string) => {
- await ReactNativeBlobUtil.fetch(
+ if (aborted) throw new AbortError();
+
+ const fetchTask = ReactNativeBlobUtil.fetch(
'PUT',
url,
{
@@ -154,6 +160,13 @@ export class NetworkFacade {
}
});
+ activeFetchTask = fetchTask;
+ try {
+ await fetchTask;
+ } finally {
+ activeFetchTask = null;
+ }
+
return fileHash;
},
);
@@ -174,7 +187,12 @@ export class NetworkFacade {
}
};
- return [wrapUploadWithCleanup(), () => null];
+ const abortable: Abortable = () => {
+ aborted = true;
+ activeFetchTask?.cancel?.();
+ };
+
+ return [wrapUploadWithCleanup(), abortable];
}
async uploadMultipart(
@@ -185,6 +203,23 @@ export class NetworkFacade {
): Promise {
const CONCURRENT_UPLOADS = 6;
const limit = pLimit(CONCURRENT_UPLOADS);
+ const activeFetchTasks = new Set>();
+ let aborted = false;
+
+ const abortHandler = () => {
+ aborted = true;
+ limit.clearQueue();
+ activeFetchTasks.forEach((t) => t.cancel?.());
+ activeFetchTasks.clear();
+ };
+
+ if (options.abortController) {
+ if (options.abortController.aborted) {
+ abortHandler();
+ } else {
+ options.abortController.addEventListener('abort', abortHandler, { once: true });
+ }
+ }
const uploadState = {
partsUploadedBytes: {} as Record,
@@ -194,8 +229,9 @@ export class NetworkFacade {
const fileInfo = await this.getFileInfo(filePath, options.partSize);
const encryptedPartPaths = this.createEncryptedPartPaths(fileInfo.parts);
+ const abortCtx = { isAborted: () => aborted, activeFetchTasks };
const uploadMultipart = async (urls: string[]) => {
- await this.processUploadParts(urls, encryptedPartPaths, uploadState, limit, fileInfo.size, options);
+ await this.processUploadParts(urls, encryptedPartPaths, uploadState, limit, fileInfo.size, options, abortCtx);
uploadState.fileHash = await this.calculateFileHash(encryptedPartPaths);
return {
@@ -216,6 +252,7 @@ export class NetworkFacade {
);
} finally {
await this.cleanupEncryptedFiles(encryptedPartPaths);
+ options.abortController?.removeEventListener('abort', abortHandler);
}
}
@@ -260,30 +297,16 @@ export class NetworkFacade {
limit: LimitFunction,
fileSize: number,
options: UploadMultipartOptions,
+ abortCtx: { isAborted: () => boolean; activeFetchTasks: Set> },
): Promise {
const uploadWithRetry = async (path: string, url: string, index: number): Promise => {
try {
- const result = await this.uploadPart(
- url,
- path,
- index,
- uploadState.partsUploadedBytes,
- fileSize,
- options.uploadingCallback,
- );
- return result;
+ return await this.uploadPart(url, path, index, uploadState.partsUploadedBytes, fileSize, options.uploadingCallback, abortCtx);
} catch (error) {
+ if ((error as Error)?.name === AbortError.errorName) throw error;
logger.error(`First attempt failed for part ${index + 1}, retrying...`);
try {
- const retryResult = await this.uploadPart(
- url,
- path,
- index,
- uploadState.partsUploadedBytes,
- fileSize,
- options.uploadingCallback,
- );
- return retryResult;
+ return await this.uploadPart(url, path, index, uploadState.partsUploadedBytes, fileSize, options.uploadingCallback, abortCtx);
} catch (retryError) {
logger.error(`Retry failed for part ${index + 1}`);
throw retryError;
@@ -309,17 +332,22 @@ export class NetworkFacade {
partsUploadedBytes: Record,
fileSize: number,
progressCallback?: (progress: number) => void,
+ abortCtx?: { isAborted: () => boolean; activeFetchTasks: Set> },
): Promise {
- try {
- const response = await ReactNativeBlobUtil.fetch(
- 'PUT',
- url,
- { 'Content-Type': 'application/octet-stream' },
- ReactNativeBlobUtil.wrap(encryptedPartPath),
- ).uploadProgress({ interval: 150 }, (sent: number) => {
- this.updateUploadProgress(index, parseInt(sent.toString()), partsUploadedBytes, fileSize, progressCallback);
- });
+ if (abortCtx?.isAborted()) throw new AbortError();
+
+ const fetchTask = ReactNativeBlobUtil.fetch(
+ 'PUT',
+ url,
+ { 'Content-Type': 'application/octet-stream' },
+ ReactNativeBlobUtil.wrap(encryptedPartPath),
+ ).uploadProgress({ interval: 150 }, (sent: number) => {
+ this.updateUploadProgress(index, parseInt(sent.toString()), partsUploadedBytes, fileSize, progressCallback);
+ });
+ abortCtx?.activeFetchTasks.add(fetchTask);
+ try {
+ const response = await fetchTask;
const etag = Platform.OS === 'android' ? response.info().headers.ETag : response.info().headers.Etag;
if (!etag) throw new Error('Missing ETag in upload response');
@@ -328,8 +356,11 @@ export class NetworkFacade {
ETag: etag,
};
} catch (error) {
+ if (abortCtx?.isAborted()) throw new AbortError();
logger.error(`Error uploading part ${index + 1}:`, error);
throw error;
+ } finally {
+ abortCtx?.activeFetchTasks.delete(fetchTask);
}
}
diff --git a/src/network/errors.ts b/src/network/errors.ts
new file mode 100644
index 000000000..c2e5f42bd
--- /dev/null
+++ b/src/network/errors.ts
@@ -0,0 +1,8 @@
+export class AbortError extends Error {
+ static readonly errorName = 'AbortError';
+
+ constructor(message = 'Upload cancelled') {
+ super(message);
+ this.name = AbortError.errorName;
+ }
+}
diff --git a/src/network/upload.ts b/src/network/upload.ts
index 8e6cbfdd4..5017175ac 100644
--- a/src/network/upload.ts
+++ b/src/network/upload.ts
@@ -2,6 +2,7 @@ import * as RNFS from '@dr.pogodin/react-native-fs';
import { logger } from '../services/common';
import { withRateLimitRetry } from '../services/common/rate-limit';
import { Abortable } from '../types';
+import { AbortError } from './errors';
import { getNetwork } from './NetworkFacade';
import { NetworkCredentials } from './requests';
@@ -28,7 +29,7 @@ export async function uploadFile(
const useMultipart = fileSize > MAX_SIZE_FOR_SINGLE_UPLOAD;
- const uploadAbortController = new AbortController();
+ const isAborted = () => params.signal?.aborted === true;
async function retryUpload(): Promise {
const MAX_TRIES = 3;
@@ -38,11 +39,13 @@ export async function uploadFile(
for (let attempt = 1; attempt <= MAX_TRIES; attempt++) {
try {
const result = await withRateLimitRetry(async () => {
+ if (isAborted()) throw new AbortError();
+
if (useMultipart) {
- return await network.uploadMultipart(bucketId, mnemonic, filePath, {
+ return network.uploadMultipart(bucketId, mnemonic, filePath, {
partSize: MULTIPART_PART_SIZE,
uploadingCallback: params.notifyProgress,
- abortController: uploadAbortController.signal,
+ abortController: params.signal,
});
}
@@ -54,11 +57,16 @@ export async function uploadFile(
onAbortableReady(abortable);
}
- return await promise;
+ params.signal?.addEventListener('abort', () => abortable(), { once: true });
+ if (isAborted()) abortable();
+
+ return promise;
}, 'Upload');
return result;
} catch (err) {
+ if ((err as Error)?.name === AbortError.errorName) throw err;
+
logger.error(`Upload attempt ${attempt} of ${MAX_TRIES} failed:`, err);
const lastTryFailed = attempt === MAX_TRIES;
diff --git a/src/screens/SettingsScreen/index.tsx b/src/screens/SettingsScreen/index.tsx
index 82f2fc26d..7ea461227 100644
--- a/src/screens/SettingsScreen/index.tsx
+++ b/src/screens/SettingsScreen/index.tsx
@@ -3,7 +3,6 @@ import {
CaretRightIcon,
FileTextIcon,
FolderSimpleIcon,
- InfoIcon,
MoonIcon,
QuestionIcon,
ShieldIcon,
@@ -12,8 +11,8 @@ import {
} from 'phosphor-react-native';
import { useRef, useState } from 'react';
import { Linking, Platform, ScrollView, View } from 'react-native';
-import EnableBackupBottomSheet from '../PhotosScreen/EnableBackupBottomSheet';
import AppSwitch from '../../components/AppSwitch';
+import EnableBackupBottomSheet from '../PhotosScreen/EnableBackupBottomSheet';
import { storageSelectors } from 'src/store/slices/storage';
import { Language } from 'src/types';
@@ -92,7 +91,7 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
Linking.openURL('mailto:hello@internxt.com');
};
const onMoreInfoPressed = () => {
- Linking.openURL('https://internxt.com');
+ Linking.openURL('https://help.internxt.com');
};
const onTermsAndConditionsPressed = () => {
Linking.openURL(appService.urls.termsAndConditions);
@@ -360,12 +359,7 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
{
key: 'photos-mobile-data',
template: (
-
+
{strings.screens.SettingsScreen.photos.mobileDataTitle}
@@ -389,7 +383,12 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
),
onPress: photosEnabled
- ? () => dispatch(setNetworkConditionThunk(networkCondition === 'wifi-and-data' ? 'wifi-only' : 'wifi-and-data'))
+ ? () =>
+ dispatch(
+ setNetworkConditionThunk(
+ networkCondition === 'wifi-and-data' ? 'wifi-only' : 'wifi-and-data',
+ ),
+ )
: undefined,
},
]}
@@ -414,21 +413,6 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
),
onPress: onSupportPressed,
},
- {
- key: 'more-information',
- template: (
-
-
-
- {strings.screens.SettingsScreen.more}
-
-
-
-
-
- ),
- onPress: onMoreInfoPressed,
- },
{
key: 'share-logs',
loading: gettingLogs,
@@ -447,28 +431,6 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
},
]}
/>
- {/* LEGAL */}
-
-
-
- {strings.screens.SettingsScreen.termsAndConditions}
-
-
-
-
-
-
- ),
- onPress: onTermsAndConditionsPressed,
- },
- ]}
- />
{/* DEBUG */}
{appService.isDevMode && (
diff --git a/src/screens/SignInScreen/index.tsx b/src/screens/SignInScreen/index.tsx
index ac2e6ef59..cb8058894 100644
--- a/src/screens/SignInScreen/index.tsx
+++ b/src/screens/SignInScreen/index.tsx
@@ -1,8 +1,8 @@
import { useState } from 'react';
-import { Dimensions, Linking, TouchableOpacity, View } from 'react-native';
+import { Dimensions, TouchableOpacity, View } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
-import { WarningCircle } from 'phosphor-react-native';
+import { WarningCircleIcon } from 'phosphor-react-native';
import { ScrollView } from 'react-native-gesture-handler';
import AppText from 'src/components/AppText';
@@ -12,11 +12,11 @@ import AppButton from '../../components/AppButton';
import AppScreen from '../../components/AppScreen';
import AppVersionWidget from '../../components/AppVersionWidget';
import { useTheme } from '../../contexts/Theme/Theme.context';
+import { openUrl } from '../../helpers/utils';
import useGetColor from '../../hooks/useColor';
import { useLanguage } from '../../hooks/useLanguage';
import analytics, { AnalyticsEventKey } from '../../services/AnalyticsService';
import appService from '../../services/AppService';
-import { logger } from '../../services/common';
import errorService from '../../services/ErrorService';
import notificationsService from '../../services/NotificationsService';
import { NotificationType } from '../../types';
@@ -31,75 +31,26 @@ function SignInScreen(): JSX.Element {
const [error, setError] = useState('');
const dimensions = Dimensions.get('screen');
- const onSignInWithBrowserPressed = async () => {
- try {
- const webAuthUrl = appService.urls.webAuth.login;
- const canOpen = await Linking.canOpenURL(webAuthUrl);
- if (canOpen) {
- await Linking.openURL(webAuthUrl);
-
- analytics.track(AnalyticsEventKey.UserSignIn, {
- method: 'browser',
- });
- } else {
- notificationsService.show({
- type: NotificationType.Error,
- text1: strings.screens.SignInScreen.errorOpeningLink,
- });
- }
- } catch (err) {
+ const onSignInWithBrowserPressed = () => {
+ openUrl(appService.urls.webAuth.login, (err) => {
const errorMessage = errorService.castError(err).message;
- logger.error('Error opening web auth URL', err);
+ notificationsService.show({ type: NotificationType.Error, text1: strings.screens.SignInScreen.errorOpeningLink });
setError(errorMessage);
- }
+ });
+ analytics.track(AnalyticsEventKey.UserSignIn, { method: 'browser' });
};
- const onSignUpWithBrowserPressed = async () => {
- try {
- const webAuthUrl = appService.urls.webAuth.signup;
-
- const canOpen = await Linking.canOpenURL(webAuthUrl);
- if (canOpen) {
- await Linking.openURL(webAuthUrl);
-
- analytics.track(AnalyticsEventKey.UserSignUp, {
- method: 'browser',
- });
- } else {
- notificationsService.show({
- type: NotificationType.Error,
- text1: strings.screens.SignUpScreen.errorOpeningLink,
- });
- }
- } catch (err) {
+ const onSignUpWithBrowserPressed = () => {
+ openUrl(appService.urls.webAuth.signup, (err) => {
const errorMessage = errorService.castError(err).message;
- logger.error('Error opening web sign up URL', err);
+ notificationsService.show({ type: NotificationType.Error, text1: strings.screens.SignUpScreen.errorOpeningLink });
setError(errorMessage);
- }
- };
-
- const onTermsAndConditionsPressed = async () => {
- try {
- const termsUrl = appService.urls.termsAndConditions;
- const canOpen = await Linking.canOpenURL(termsUrl);
- if (canOpen) {
- await Linking.openURL(termsUrl);
- }
- } catch (err) {
- logger.error('Error opening terms and conditions URL', err);
- }
+ });
+ analytics.track(AnalyticsEventKey.UserSignUp, { method: 'browser' });
};
- const onNeedHelpPressed = async () => {
- try {
- const helpUrl = appService.urls.help;
- const canOpen = await Linking.canOpenURL(helpUrl);
- if (canOpen) {
- await Linking.openURL(helpUrl);
- }
- } catch (err) {
- logger.error('Error opening help URL', err);
- }
+ const onNeedHelpPressed = () => {
+ openUrl(appService.urls.help);
};
const renderErrorMessage = () => {
@@ -109,7 +60,7 @@ function SignInScreen(): JSX.Element {
return (
-
+ {error}
);
@@ -118,7 +69,7 @@ function SignInScreen(): JSX.Element {
return (
{isDark ? (
@@ -180,11 +131,6 @@ function SignInScreen(): JSX.Element {
-
-
- {strings.screens.SignInScreen.termsAndConditions}
-
-
{strings.screens.SignInScreen.needHelp}
diff --git a/src/screens/drive/DriveFolderScreen/DriveFolderScreen.helpers.ts b/src/screens/drive/DriveFolderScreen/DriveFolderScreen.helpers.ts
index 7f63dac84..a0e0d1d2c 100644
--- a/src/screens/drive/DriveFolderScreen/DriveFolderScreen.helpers.ts
+++ b/src/screens/drive/DriveFolderScreen/DriveFolderScreen.helpers.ts
@@ -40,15 +40,22 @@ export const buildDeepLinkRoutes = (folderUuid: string, ancestors: FolderAncesto
return routes;
};
+const FOLDER_UPLOAD_ID_PREFIX = 'folder-upload-';
+
+export const isFolderUploadItem = (item: DriveListItem): boolean => item.id.startsWith(FOLDER_UPLOAD_ID_PREFIX);
+
export const buildFolderUploadListItem = (state: FolderUploadState): DriveListItem => ({
- id: `folder-upload-${state.uploadId}`,
+ id: `${FOLDER_UPLOAD_ID_PREFIX}${state.uploadId}`,
status: DriveItemStatus.Uploading,
progress: state.totalFiles > 0 ? state.uploadedFiles / state.totalFiles : 0,
- folderUploadProgress: {
- uploadedFiles: state.uploadedFiles,
- totalFiles: state.totalFiles,
- failedFiles: state.failedFiles,
- },
+ folderUploadProgress:
+ state.status === 'scanning'
+ ? undefined
+ : {
+ uploadedFiles: state.uploadedFiles,
+ totalFiles: state.totalFiles,
+ failedFiles: state.failedFiles,
+ },
data: {
id: -1,
uuid: state.uploadId,
diff --git a/src/screens/drive/DriveFolderScreen/DriveFolderScreen.tsx b/src/screens/drive/DriveFolderScreen/DriveFolderScreen.tsx
index 13069f71b..e57a2c1d4 100644
--- a/src/screens/drive/DriveFolderScreen/DriveFolderScreen.tsx
+++ b/src/screens/drive/DriveFolderScreen/DriveFolderScreen.tsx
@@ -24,7 +24,7 @@ import { DriveListType, SortDirection, SortType } from '../../../types/drive/ui'
import { DriveScreenProps, DriveStackParamList } from '../../../types/navigation';
import { DriveFolderEmpty } from './DriveFolderEmpty';
import { DriveFolderError } from './DriveFolderError';
-import { buildFolderUploadListItem } from './DriveFolderScreen.helpers';
+import { buildFolderUploadListItem, isFolderUploadItem } from './DriveFolderScreen.helpers';
import { DriveFolderScreenHeader } from './DriveFolderScreenHeader';
import { useDeepLinkNavigationResolver } from './useDeepLinkNavigationResolver';
@@ -218,7 +218,7 @@ export function DriveFolderScreen({ navigation }: DriveScreenProps<'DriveFolder'
}),
);
handleOnFilePress(driveItem);
- } else if (driveItem.data.uuid) {
+ } else if (driveItem.data.uuid && !isFolderUploadItem(driveItem)) {
driveCtx.loadFolderContent(driveItem.data.uuid, { focusFolder: true, resetPagination: true }).catch((error) => {
errorService.reportError(error);
const err = errorService.castError(error, 'content');
@@ -239,7 +239,7 @@ export function DriveFolderScreen({ navigation }: DriveScreenProps<'DriveFolder'
};
const handleDriveItemActionsPress = (driveItem: DriveListItem) => {
- if (driveItem.id.startsWith('folder-upload-')) {
+ if (isFolderUploadItem(driveItem)) {
const uploadId = driveItem.data.uuid;
folderUploadCancellationService.cancel(uploadId);
dispatch(driveActions.removeFolderUpload(uploadId));
diff --git a/src/services/AsyncStorageService.ts b/src/services/AsyncStorageService.ts
index 67e33828a..860de17ec 100644
--- a/src/services/AsyncStorageService.ts
+++ b/src/services/AsyncStorageService.ts
@@ -4,7 +4,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { AsyncStorageKey } from '../types';
import secureStorageService from './SecureStorageService';
-const SENSITIVE_KEYS = [AsyncStorageKey.Token, AsyncStorageKey.PhotosToken, AsyncStorageKey.User];
+const SENSITIVE_KEYS = [AsyncStorageKey.Token, AsyncStorageKey.PhotosToken, AsyncStorageKey.User, AsyncStorageKey.ThemePreference];
class AsyncStorageService {
private isSensitiveKey(key: AsyncStorageKey): boolean {
diff --git a/src/services/drive/file/utils/emptyFileErrors.ts b/src/services/drive/file/utils/emptyFileErrors.ts
new file mode 100644
index 000000000..c216f8dd9
--- /dev/null
+++ b/src/services/drive/file/utils/emptyFileErrors.ts
@@ -0,0 +1,13 @@
+export class EmptyFileNotAllowedError extends Error {
+ constructor() {
+ super('Empty files require a paid plan');
+ this.name = 'EmptyFileNotAllowedError';
+ Object.setPrototypeOf(this, EmptyFileNotAllowedError.prototype);
+ }
+}
+
+export const isEmptyFilePlanError = (error: unknown): boolean => {
+ const err = error as { status?: unknown; response?: { status?: unknown } };
+ const status = Number(err?.status ?? err?.response?.status);
+ return status === 402;
+};
diff --git a/src/services/drive/file/utils/uploadFileUtils.ts b/src/services/drive/file/utils/uploadFileUtils.ts
index 504aaad73..4de69589d 100644
--- a/src/services/drive/file/utils/uploadFileUtils.ts
+++ b/src/services/drive/file/utils/uploadFileUtils.ts
@@ -20,9 +20,11 @@ import { Dispatch } from 'react';
import { Action } from 'redux';
import { DriveFoldersTreeNode } from '../../../../contexts/Drive';
import { getEnvironmentConfigFromUser } from '../../../../lib/network';
+import { uiActions } from '../../../../store/slices/ui';
import analyticsService, { DriveAnalyticsEvent } from '../../../AnalyticsService';
import { logger } from '../../../common';
import { uploadService } from '../../../common/network/upload/upload.service';
+import { EmptyFileNotAllowedError, isEmptyFilePlanError } from './emptyFileErrors';
import { BucketNotFoundError } from './upload.errors';
/**
@@ -232,7 +234,12 @@ export async function createEmptyFileEntry(bucketId: string, file: UploadingFile
creationTime: creationTimeISO,
};
- return uploadService.createFileEntry(fileEntry);
+ try {
+ return await uploadService.createFileEntry(fileEntry);
+ } catch (err) {
+ if (isEmptyFilePlanError(err)) throw new EmptyFileNotAllowedError();
+ throw err;
+ }
}
/**
@@ -265,6 +272,11 @@ export async function uploadSingleFile(
}
uploadSuccess(file);
} catch (e) {
+ if (e instanceof EmptyFileNotAllowedError) {
+ dispatch(uiActions.setShowEmptyFileNotAllowedModal(true));
+ dispatch(driveActions.uploadFileFinished());
+ return;
+ }
const err = e as Error;
errorService.reportError(err, {
extra: {
@@ -280,3 +292,4 @@ export async function uploadSingleFile(
dispatch(driveActions.uploadFileFinished());
}
}
+
diff --git a/src/services/drive/folder/folderOrchestration.service.spec.ts b/src/services/drive/folder/folderOrchestration.service.spec.ts
index ccfbf14f9..26dd611ef 100644
--- a/src/services/drive/folder/folderOrchestration.service.spec.ts
+++ b/src/services/drive/folder/folderOrchestration.service.spec.ts
@@ -1,5 +1,5 @@
-import { uploadFolderContents, getMaxDepth } from './folderOrchestration.service';
import { FolderTree, FolderTreeNode } from '../../../types/drive/folderUpload';
+import { getMaxDepth, uploadFolderContents } from './folderOrchestration.service';
const mockCreateFolder = jest.fn();
const mockCheckDuplicatedFolders = jest.fn();
@@ -46,19 +46,19 @@ const makeSignal = (aborted = false): AbortSignal => {
};
describe('getMaxDepth', () => {
- it('when dirs is empty, then returns 0', () => {
+ test('when dirs is empty, then returns 0', () => {
expect(getMaxDepth([])).toBe(0);
});
- it('when dirs has a single flat entry, then returns 1', () => {
+ test('when dirs has a single flat entry, then returns 1', () => {
expect(getMaxDepth([makeDir('photos')])).toBe(1);
});
- it('when dirs has two entries one level deep, then returns 2', () => {
+ test('when dirs has two entries one level deep, then returns 2', () => {
expect(getMaxDepth([makeDir('photos'), makeDir('photos/vacation')])).toBe(2);
});
- it('when dirs has three levels of nesting, then returns 3', () => {
+ test('when dirs has three levels of nesting, then returns 3', () => {
expect(getMaxDepth([makeDir('a'), makeDir('a/b'), makeDir('a/b/c')])).toBe(3);
});
});
@@ -75,7 +75,7 @@ describe('uploadFolderContents', () => {
});
describe('folder tree is mapped and uploaded correctly', () => {
- it('when tree is empty, then returns all-zero counts', async () => {
+ test('when tree is empty, then returns all-zero counts', async () => {
const result = await uploadFolderContents({
tree: makeTree([], []),
rootParentUuid: ROOT,
@@ -85,14 +85,19 @@ describe('uploadFolderContents', () => {
});
expect(result).toEqual({
- totalFiles: 0, uploadedFiles: 0, failedFiles: 0,
- totalFolders: 0, createdFolders: 0, failedFolders: 0,
+ totalFiles: 0,
+ uploadedFiles: 0,
+ failedFiles: 0,
+ skippedFiles: 0,
+ totalFolders: 0,
+ createdFolders: 0,
+ failedFolders: 0,
cancelled: false,
});
expect(uploadFile).not.toHaveBeenCalled();
});
- it('when tree has only root-level files, then uploads all files to rootParentUuid', async () => {
+ test('when tree has only root-level files, then uploads all files to rootParentUuid', async () => {
const files = [makeFile('a.jpg', ''), makeFile('b.jpg', ''), makeFile('c.jpg', '')];
const result = await uploadFolderContents({
@@ -104,8 +109,13 @@ describe('uploadFolderContents', () => {
});
expect(result).toEqual({
- totalFiles: 3, uploadedFiles: 3, failedFiles: 0,
- totalFolders: 0, createdFolders: 0, failedFolders: 0,
+ totalFiles: 3,
+ uploadedFiles: 3,
+ failedFiles: 0,
+ skippedFiles: 0,
+ totalFolders: 0,
+ createdFolders: 0,
+ failedFolders: 0,
cancelled: false,
});
expect(uploadFile).toHaveBeenCalledTimes(3);
@@ -114,7 +124,7 @@ describe('uploadFolderContents', () => {
});
});
- it('when tree has one subfolder with one file, then creates the folder and uploads the file to it', async () => {
+ test('when tree has one subfolder with one file, then creates the folder and uploads the file to it', async () => {
// Arrange
mockCreateFolder.mockResolvedValue({ uuid: 'photos-uuid' });
const dirs = [makeDir('photos')];
@@ -135,11 +145,9 @@ describe('uploadFolderContents', () => {
expect(uploadFile).toHaveBeenCalledWith(files[0], 'photos-uuid', expect.any(AbortSignal));
});
- it('when tree has two levels of nested dirs with files, then creates folders in order and routes files to correct parent UUIDs', async () => {
+ test('when tree has two levels of nested dirs with files, then creates folders in order and routes files to correct parent UUIDs', async () => {
// Arrange
- mockCreateFolder
- .mockResolvedValueOnce({ uuid: 'photos-uuid' })
- .mockResolvedValueOnce({ uuid: 'vacation-uuid' });
+ mockCreateFolder.mockResolvedValueOnce({ uuid: 'photos-uuid' }).mockResolvedValueOnce({ uuid: 'vacation-uuid' });
const dirs = [makeDir('photos'), makeDir('photos/vacation')];
const files = [makeFile('root.jpg', ''), makeFile('photo.jpg', 'photos'), makeFile('vac.jpg', 'photos/vacation')];
@@ -154,8 +162,13 @@ describe('uploadFolderContents', () => {
// Assert
expect(result).toEqual({
- totalFiles: 3, uploadedFiles: 3, failedFiles: 0,
- totalFolders: 2, createdFolders: 2, failedFolders: 0,
+ totalFiles: 3,
+ uploadedFiles: 3,
+ failedFiles: 0,
+ skippedFiles: 0,
+ totalFolders: 2,
+ createdFolders: 2,
+ failedFolders: 0,
cancelled: false,
});
expect(mockCreateFolder).toHaveBeenCalledWith(ROOT, 'photos');
@@ -165,11 +178,9 @@ describe('uploadFolderContents', () => {
expect(uploadFile).toHaveBeenCalledWith(files[2], 'vacation-uuid', expect.any(AbortSignal));
});
- it('when tree has nested dirs but no files, then creates all folders and does not upload any file', async () => {
+ test('when tree has nested dirs but no files, then creates all folders and does not upload any file', async () => {
// Arrange
- mockCreateFolder
- .mockResolvedValueOnce({ uuid: 'a-uuid' })
- .mockResolvedValueOnce({ uuid: 'ab-uuid' });
+ mockCreateFolder.mockResolvedValueOnce({ uuid: 'a-uuid' }).mockResolvedValueOnce({ uuid: 'ab-uuid' });
// Act
const result = await uploadFolderContents({
@@ -182,8 +193,13 @@ describe('uploadFolderContents', () => {
// Assert
expect(result).toEqual({
- totalFiles: 0, uploadedFiles: 0, failedFiles: 0,
- totalFolders: 2, createdFolders: 2, failedFolders: 0,
+ totalFiles: 0,
+ uploadedFiles: 0,
+ failedFiles: 0,
+ skippedFiles: 0,
+ totalFolders: 2,
+ createdFolders: 2,
+ failedFolders: 0,
cancelled: false,
});
expect(mockCreateFolder).toHaveBeenCalledWith(ROOT, 'a');
@@ -193,7 +209,7 @@ describe('uploadFolderContents', () => {
});
describe('destination folder already exists on the server', () => {
- it('when createFolder returns 409, then calls checkDuplicatedFolders and uploads to the existing UUID', async () => {
+ test('when createFolder returns 409, then calls checkDuplicatedFolders and uploads to the existing UUID', async () => {
// Arrange
mockCreateFolder.mockRejectedValueOnce({ status: 409, message: 'Already exists' });
mockCheckDuplicatedFolders.mockResolvedValueOnce({
@@ -214,7 +230,7 @@ describe('uploadFolderContents', () => {
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), 'existing-uuid', expect.any(AbortSignal));
});
- it('when folder name has special characters and createFolder returns 409, then passes the name directly to checkDuplicatedFolders', async () => {
+ test('when folder name has special characters and createFolder returns 409, then passes the name directly to checkDuplicatedFolders', async () => {
// Arrange
mockCreateFolder.mockRejectedValueOnce({ status: 409 });
mockCheckDuplicatedFolders.mockResolvedValueOnce({
@@ -235,7 +251,7 @@ describe('uploadFolderContents', () => {
expect(uploadFile).toHaveBeenCalledWith(expect.anything(), 'cafe-uuid', expect.any(AbortSignal));
});
- it('when createFolder returns 409 and checkDuplicatedFolders returns empty, then counts the dependent file as failed', async () => {
+ test('when createFolder returns 409 and checkDuplicatedFolders returns empty, then counts the dependent file as failed', async () => {
// Arrange
mockCreateFolder.mockRejectedValueOnce({ status: 409 });
mockCheckDuplicatedFolders.mockResolvedValueOnce({ existentFolders: [] });
@@ -254,7 +270,7 @@ describe('uploadFolderContents', () => {
expect(result.uploadedFiles).toBe(0);
});
- it('when createFolder returns a non-409 error, then does not call checkDuplicatedFolders', async () => {
+ test('when createFolder returns a non-409 error, then does not call checkDuplicatedFolders', async () => {
// Arrange
mockCreateFolder.mockRejectedValueOnce({ status: 500, message: 'Internal Server Error' });
@@ -274,7 +290,7 @@ describe('uploadFolderContents', () => {
});
describe('a file failure does not abort the rest of the upload', () => {
- it('when one file upload fails, then counts it as failed and continues uploading the rest', async () => {
+ test('when one file upload fails, then counts it as failed and continues uploading the rest', async () => {
// Arrange
uploadFile.mockRejectedValueOnce(new Error('Network error')).mockResolvedValue(undefined);
const files = [makeFile('a.jpg', ''), makeFile('b.jpg', ''), makeFile('c.jpg', '')];
@@ -294,7 +310,7 @@ describe('uploadFolderContents', () => {
expect(result.cancelled).toBe(false);
});
- it('when all file uploads fail, then counts all as failed and does not mark as cancelled', async () => {
+ test('when all file uploads fail, then counts all as failed and does not mark as cancelled', async () => {
// Arrange
uploadFile.mockRejectedValue(new Error('Network error'));
const files = [makeFile('a.jpg', ''), makeFile('b.jpg', '')];
@@ -316,7 +332,7 @@ describe('uploadFolderContents', () => {
});
describe('progress is reported as files are processed', () => {
- it('when files succeed and fail, then calls onProgress once per file', async () => {
+ test('when files succeed and fail, then calls onProgress once per file', async () => {
uploadFile.mockRejectedValueOnce(new Error('fail')).mockResolvedValue(undefined);
const files = [makeFile('a.jpg', ''), makeFile('b.jpg', ''), makeFile('c.jpg', '')];
@@ -331,7 +347,7 @@ describe('uploadFolderContents', () => {
expect(onProgress).toHaveBeenCalledTimes(3);
});
- it('when all files succeed, then last onProgress call reports the total uploaded count', async () => {
+ test('when all files succeed, then last onProgress call reports the total uploaded count', async () => {
const files = [makeFile('a.jpg', ''), makeFile('b.jpg', '')];
await uploadFolderContents({
@@ -347,7 +363,7 @@ describe('uploadFolderContents', () => {
});
describe('upload is stopped when a cancellation signal is received', () => {
- it('when signal is already aborted before calling, then returns cancelled=true without uploading any file', async () => {
+ test('when signal is already aborted before calling, then returns cancelled=true without uploading any file', async () => {
const result = await uploadFolderContents({
tree: makeTree([], [makeFile('a.jpg', ''), makeFile('b.jpg', '')]),
rootParentUuid: ROOT,
@@ -361,7 +377,7 @@ describe('uploadFolderContents', () => {
expect(uploadFile).not.toHaveBeenCalled();
});
- it('when uploadFile throws AbortError, then sets cancelled=true and does not increment failedFiles', async () => {
+ test('when uploadFile throws AbortError, then sets cancelled=true and does not increment failedFiles', async () => {
// Arrange
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
@@ -381,7 +397,7 @@ describe('uploadFolderContents', () => {
expect(result.cancelled).toBe(true);
});
- it('when signal is aborted after first upload, then does not start more than the concurrent limit', async () => {
+ test('when signal is aborted after first upload, then does not start more than the concurrent limit', async () => {
// Arrange
const abortController = new AbortController();
let callCount = 0;
@@ -408,7 +424,7 @@ describe('uploadFolderContents', () => {
expect(uploadFile.mock.calls.length).toBeLessThanOrEqual(3);
});
- it('when signal is pre-aborted and tree is empty, then returns all-zero counts with cancelled=true', async () => {
+ test('when signal is pre-aborted and tree is empty, then returns all-zero counts with cancelled=true', async () => {
const result = await uploadFolderContents({
tree: makeTree([], []),
rootParentUuid: ROOT,
@@ -418,15 +434,20 @@ describe('uploadFolderContents', () => {
});
expect(result).toEqual({
- totalFiles: 0, uploadedFiles: 0, failedFiles: 0,
- totalFolders: 0, createdFolders: 0, failedFolders: 0,
+ totalFiles: 0,
+ uploadedFiles: 0,
+ failedFiles: 0,
+ skippedFiles: 0,
+ totalFolders: 0,
+ createdFolders: 0,
+ failedFolders: 0,
cancelled: true,
});
});
});
describe('simultaneous uploads do not interfere with each other', () => {
- it('when two uploads run concurrently with independent signals, then both complete with their own file counts', async () => {
+ test('when two uploads run concurrently with independent signals, then both complete with their own file counts', async () => {
// Arrange
const onProgressA = jest.fn();
const onProgressB = jest.fn();
@@ -453,20 +474,30 @@ describe('uploadFolderContents', () => {
// Assert
expect(resultA).toEqual({
- totalFiles: 2, uploadedFiles: 2, failedFiles: 0,
- totalFolders: 0, createdFolders: 0, failedFolders: 0,
+ totalFiles: 2,
+ uploadedFiles: 2,
+ failedFiles: 0,
+ skippedFiles: 0,
+ totalFolders: 0,
+ createdFolders: 0,
+ failedFolders: 0,
cancelled: false,
});
expect(resultB).toEqual({
- totalFiles: 3, uploadedFiles: 3, failedFiles: 0,
- totalFolders: 0, createdFolders: 0, failedFolders: 0,
+ totalFiles: 3,
+ uploadedFiles: 3,
+ failedFiles: 0,
+ skippedFiles: 0,
+ totalFolders: 0,
+ createdFolders: 0,
+ failedFolders: 0,
cancelled: false,
});
expect(uploadFileA).toHaveBeenCalledTimes(2);
expect(uploadFileB).toHaveBeenCalledTimes(3);
});
- it('when signal A is aborted mid-upload, then upload B completes successfully', async () => {
+ test('when signal A is aborted mid-upload, then upload B completes successfully', async () => {
// Arrange
const firstUploadController = new AbortController();
const uploadFileA = jest.fn().mockImplementation(async () => {
diff --git a/src/services/drive/folder/folderOrchestration.service.ts b/src/services/drive/folder/folderOrchestration.service.ts
index 98ae29cfa..f09a1bd35 100644
--- a/src/services/drive/folder/folderOrchestration.service.ts
+++ b/src/services/drive/folder/folderOrchestration.service.ts
@@ -1,5 +1,7 @@
import { logger } from '@internxt-mobile/services/common';
+import { EmptyFileNotAllowedError } from '@internxt-mobile/services/drive/file/utils/emptyFileErrors';
import pLimit from 'p-limit';
+import { AbortError } from '../../../network/errors';
import { FolderTree, FolderTreeNode, FolderUploadResult, UploadFileCallback } from '../../../types/drive/folderUpload';
import { HTTP_CONFLICT, HTTP_NOT_FOUND } from '../../common/httpStatusCodes';
import { driveFolderService } from './driveFolder.service';
@@ -111,13 +113,13 @@ export const uploadFolderContents = async ({
continue;
}
const folderPromise = parentPromise.then(async (parentUuid) => {
- if (signal.aborted) throw new DOMException('Upload cancelled', 'AbortError');
+ if (signal.aborted) throw new AbortError();
const uuid = await createFolderWithMerge(parentUuid, directory.name);
createdFolders++;
return uuid;
});
folderPromise.catch((err) => {
- if ((err as Error)?.name !== 'AbortError') failedFolders++;
+ if ((err as Error)?.name !== AbortError.errorName) failedFolders++;
});
folderCreationPromises.set(directory.relativePath, folderPromise);
}
@@ -128,6 +130,7 @@ export const uploadFolderContents = async ({
totalFiles: 0,
uploadedFiles: 0,
failedFiles: 0,
+ skippedFiles: 0,
totalFolders,
createdFolders,
failedFolders,
@@ -137,6 +140,7 @@ export const uploadFolderContents = async ({
let uploadedFiles = 0;
let failedFiles = 0;
+ let skippedFiles = 0;
let wasCancelled = false;
const uploadLimit = pLimit(FOLDER_UPLOAD_CONCURRENCY);
@@ -168,8 +172,11 @@ export const uploadFolderContents = async ({
logger.info(TAG, `created: "${file.relativePath}" (${uploadedFiles}/${totalFiles})`);
} catch (err) {
const error = err as Error;
- if (error.name === 'AbortError') {
+ if (error.name === AbortError.errorName) {
wasCancelled = true;
+ } else if (err instanceof EmptyFileNotAllowedError) {
+ skippedFiles++;
+ logger.info(TAG, `skipped empty file: "${file.relativePath}" (plan restriction)`);
} else {
failedFiles++;
logger.error(TAG, `failed creation: "${file.relativePath}": ${error.message}`);
@@ -185,6 +192,7 @@ export const uploadFolderContents = async ({
totalFiles,
uploadedFiles,
failedFiles,
+ skippedFiles,
totalFolders,
createdFolders,
failedFolders,
diff --git a/src/services/drive/folder/folderTraversal.service.spec.ts b/src/services/drive/folder/folderTraversal.service.spec.ts
index 7dcc74256..618fcfd7b 100644
--- a/src/services/drive/folder/folderTraversal.service.spec.ts
+++ b/src/services/drive/folder/folderTraversal.service.spec.ts
@@ -59,7 +59,7 @@ describe('folderTraversalService.traverseFolder', () => {
});
describe('when the URI uses the file:// scheme', () => {
- it('when the folder is empty, then it returns empty dirs and files arrays', async () => {
+ test('when the folder is empty, then it returns empty dirs and files arrays', async () => {
mockReadDir.mockResolvedValue([]);
const result = await folderTraversalService.traverseFolder('file:///var/mobile/Photos');
@@ -67,7 +67,7 @@ describe('folderTraversalService.traverseFolder', () => {
expect(result).toEqual({ dirs: [], files: [] });
});
- it('when the folder contains only files, then it returns them with correct relativePath and parentPath', async () => {
+ test('when the folder contains only files, then it returns them with correct relativePath and parentPath', async () => {
mockReadDir.mockResolvedValue([
mockFile('photo1.jpg', '/var/mobile/Photos/photo1.jpg', 2048),
mockFile('photo2.jpg', '/var/mobile/Photos/photo2.jpg', 3072),
@@ -96,7 +96,7 @@ describe('folderTraversalService.traverseFolder', () => {
]);
});
- it('when the folder has nested subdirectories, then it traverses all levels in DFS pre-order with correct relativePath and parentPath', async () => {
+ test('when the folder has nested subdirectories, then it traverses all levels in DFS pre-order with correct relativePath and parentPath', async () => {
const root = '/var/mobile/Root';
mockReadDir.mockImplementation((path: string) => {
@@ -164,7 +164,7 @@ describe('folderTraversalService.traverseFolder', () => {
]);
});
- it('when the folder contains more than 3000 files, then it throws FolderTooLargeError and shows an alert', async () => {
+ test('when the folder contains more than 3000 files, then it throws FolderTooLargeError and shows an alert', async () => {
const files = Array.from({ length: 3001 }, (_, i) => mockFile(`file${i}.txt`, `/var/mobile/Big/file${i}.txt`));
mockReadDir.mockResolvedValue(files);
@@ -177,7 +177,7 @@ describe('folderTraversalService.traverseFolder', () => {
);
});
- it('when the folder contains exactly 3000 files, then it resolves without throwing', async () => {
+ test('when the folder contains exactly 3000 files, then it resolves without throwing', async () => {
const files = Array.from({ length: 3000 }, (_, i) => mockFile(`file${i}.txt`, `/var/mobile/Big/file${i}.txt`));
mockReadDir.mockResolvedValue(files);
@@ -188,7 +188,7 @@ describe('folderTraversalService.traverseFolder', () => {
expect(mockAlert).not.toHaveBeenCalled();
});
- it('when the root path contains percent-encoded characters, then it decodes them before reading', async () => {
+ test('when the root path contains percent-encoded characters, then it decodes them before reading', async () => {
mockReadDir.mockResolvedValue([mockFile('a.txt', '/var/mobile/My Folder/a.txt', 10)]);
const result = await folderTraversalService.traverseFolder('file:///var/mobile/My%20Folder');
@@ -207,22 +207,34 @@ describe('folderTraversalService.traverseFolder', () => {
const safNestedUri = (dir: string, name: string) =>
`${treeUri}/document/primary%3ADownload%2F${encodeURIComponent(dir)}%2F${encodeURIComponent(name)}`;
- const infoFile = (uri: string, size = 1024) => ({ exists: true as const, isDirectory: false, size, uri, modificationTime: 0 });
+ const infoFile = (uri: string, size = 1024) => ({
+ exists: true as const,
+ isDirectory: false,
+ size,
+ uri,
+ modificationTime: 0,
+ });
const infoDir = (uri: string) => ({ exists: true as const, isDirectory: true, size: 0, uri, modificationTime: 0 });
- it('when the folder is empty, then it returns empty dirs and files arrays', async () => {
- mockReadDirectoryAsync.mockResolvedValue([]);
+ test('when the folder is empty, then it returns empty dirs and files arrays', async () => {
+ mockReadDirectoryAsync.mockImplementation((uri: string) => {
+ if (uri === treeUri) return Promise.resolve([]);
+ return Promise.reject(new Error('Not a directory'));
+ });
const result = await folderTraversalService.traverseFolder(treeUri);
expect(result).toEqual({ dirs: [], files: [] });
});
- it('when the folder contains only files, then it returns them with correct relativePath and parentPath', async () => {
+ test('when the folder contains only files, then it returns them with correct relativePath and parentPath', async () => {
const uri1 = safFileUri('photo1.jpg');
const uri2 = safFileUri('photo2.jpg');
- mockReadDirectoryAsync.mockResolvedValue([uri1, uri2]);
+ mockReadDirectoryAsync.mockImplementation((uri: string) => {
+ if (uri === treeUri) return Promise.resolve([uri1, uri2]);
+ return Promise.reject(new Error('Not a directory'));
+ });
mockGetInfoAsync.mockImplementation((uri: string) => {
if (uri === uri1) return Promise.resolve(infoFile(uri1, 2048));
if (uri === uri2) return Promise.resolve(infoFile(uri2, 3072));
@@ -237,7 +249,7 @@ describe('folderTraversalService.traverseFolder', () => {
]);
});
- it('when the folder has nested subdirectories, then it traverses all levels in DFS pre-order with correct relativePath and parentPath', async () => {
+ test('when the folder has nested subdirectories, then it traverses all levels in DFS pre-order with correct relativePath and parentPath', async () => {
const level1Uri = safDirUri('level1');
const deepTxtUri = safNestedUri('level1', 'deep.txt');
const rootTxtUri = safFileUri('root.txt');
@@ -245,7 +257,7 @@ describe('folderTraversalService.traverseFolder', () => {
mockReadDirectoryAsync.mockImplementation((uri: string) => {
if (uri === treeUri) return Promise.resolve([level1Uri, rootTxtUri]);
if (uri === level1Uri) return Promise.resolve([deepTxtUri]);
- return Promise.resolve([]);
+ return Promise.reject(new Error('Not a directory'));
});
mockGetInfoAsync.mockImplementation((uri: string) => {
if (uri === level1Uri) return Promise.resolve(infoDir(level1Uri));
@@ -259,15 +271,25 @@ describe('folderTraversalService.traverseFolder', () => {
{ relativePath: 'level1', parentPath: '', name: 'level1', isDirectory: true, size: 0, uri: level1Uri },
]);
expect(result.files).toEqual([
- { relativePath: 'level1/deep.txt', parentPath: 'level1', name: 'deep.txt', isDirectory: false, size: 300, uri: deepTxtUri },
+ {
+ relativePath: 'level1/deep.txt',
+ parentPath: 'level1',
+ name: 'deep.txt',
+ isDirectory: false,
+ size: 300,
+ uri: deepTxtUri,
+ },
{ relativePath: 'root.txt', parentPath: '', name: 'root.txt', isDirectory: false, size: 100, uri: rootTxtUri },
]);
});
- it('when the document URI contains percent-encoded characters in the name, then it decodes them correctly', async () => {
+ test('when the document URI contains percent-encoded characters in the name, then it decodes them correctly', async () => {
const uri = safFileUri('my file.txt');
- mockReadDirectoryAsync.mockResolvedValue([uri]);
+ mockReadDirectoryAsync.mockImplementation((calledUri: string) => {
+ if (calledUri === treeUri) return Promise.resolve([uri]);
+ return Promise.reject(new Error('Not a directory'));
+ });
mockGetInfoAsync.mockResolvedValue(infoFile(uri, 512));
const result = await folderTraversalService.traverseFolder(treeUri);
@@ -276,9 +298,12 @@ describe('folderTraversalService.traverseFolder', () => {
expect(result.files[0].relativePath).toBe('my file.txt');
});
- it('when the folder contains more than 3000 files, then it throws FolderTooLargeError and shows an alert', async () => {
+ test('when the folder contains more than 3000 files, then it throws FolderTooLargeError and shows an alert', async () => {
const uris = Array.from({ length: 3001 }, (_, i) => safFileUri(`file${i}.txt`));
- mockReadDirectoryAsync.mockResolvedValue(uris);
+ mockReadDirectoryAsync.mockImplementation((uri: string) => {
+ if (uri === treeUri) return Promise.resolve(uris);
+ return Promise.reject(new Error('Not a directory'));
+ });
mockGetInfoAsync.mockImplementation((uri: string) => Promise.resolve(infoFile(uri)));
await expect(folderTraversalService.traverseFolder(treeUri)).rejects.toBeInstanceOf(FolderTooLargeError);
@@ -288,9 +313,12 @@ describe('folderTraversalService.traverseFolder', () => {
);
});
- it('when the folder contains exactly 3000 files, then it resolves without throwing', async () => {
+ test('when the folder contains exactly 3000 files, then it resolves without throwing', async () => {
const uris = Array.from({ length: 3000 }, (_, i) => safFileUri(`file${i}.txt`));
- mockReadDirectoryAsync.mockResolvedValue(uris);
+ mockReadDirectoryAsync.mockImplementation((uri: string) => {
+ if (uri === treeUri) return Promise.resolve(uris);
+ return Promise.reject(new Error('Not a directory'));
+ });
mockGetInfoAsync.mockImplementation((uri: string) => Promise.resolve(infoFile(uri)));
const result = await folderTraversalService.traverseFolder(treeUri);
@@ -299,10 +327,154 @@ describe('folderTraversalService.traverseFolder', () => {
expect(result.dirs).toHaveLength(0);
expect(mockAlert).not.toHaveBeenCalled();
});
+
+ test('when getInfoAsync rejects (throws), then it falls back gracefully and still processes the item', async () => {
+ const uri = safFileUri('photo.jpg');
+
+ mockReadDirectoryAsync.mockImplementation((calledUri: string) => {
+ if (calledUri === treeUri) return Promise.resolve([uri]);
+ return Promise.reject(new Error('Not a directory'));
+ });
+ mockGetInfoAsync.mockRejectedValue(new Error('Function not implemented'));
+
+ const result = await folderTraversalService.traverseFolder(treeUri);
+
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0].name).toBe('photo.jpg');
+ expect(result.files[0].size).toBe(0);
+ });
+
+ describe('SAF volume prefix stripping', () => {
+ test('when info.name returns a volume-prefixed name (patch inactive), then it strips the prefix', async () => {
+ const uri = safFileUri('IMG_20260417.jpeg');
+
+ mockReadDirectoryAsync.mockImplementation((calledUri: string) => {
+ if (calledUri === treeUri) return Promise.resolve([uri]);
+ return Promise.reject(new Error('Not a directory'));
+ });
+ // Simulates the expo-file-system patch being inactive: info.name carries the SAF prefix
+ mockGetInfoAsync.mockResolvedValue({ ...infoFile(uri, 512), name: 'primary:IMG_20260417.jpeg' });
+
+ const result = await folderTraversalService.traverseFolder(treeUri);
+
+ expect(result.files[0].name).toBe('IMG_20260417.jpeg');
+ expect(result.files[0].relativePath).toBe('IMG_20260417.jpeg');
+ });
+
+ test('when the URI encodes a root-level name without a slash, then it strips the volume prefix from the document segment', async () => {
+ // URI like: .../document/primary%3ADCIM (no slash after the prefix)
+ const uri = `${treeUri}/document/primary%3ADCIM`;
+
+ mockReadDirectoryAsync.mockImplementation((calledUri: string) => {
+ if (calledUri === treeUri) return Promise.resolve([uri]);
+ return Promise.reject(new Error('Not a directory'));
+ });
+ mockGetInfoAsync.mockResolvedValue({ ...infoFile(uri, 100), name: undefined });
+
+ const result = await folderTraversalService.traverseFolder(treeUri);
+
+ expect(result.files[0].name).toBe('DCIM');
+ });
+
+ test('when a folder name legitimately contains a colon not matching the whitelist, then it is preserved', async () => {
+ const uri = safFileUri('notes: draft.txt');
+
+ mockReadDirectoryAsync.mockImplementation((calledUri: string) => {
+ if (calledUri === treeUri) return Promise.resolve([uri]);
+ return Promise.reject(new Error('Not a directory'));
+ });
+ mockGetInfoAsync.mockResolvedValue({ ...infoFile(uri, 200), name: 'notes: draft.txt' });
+
+ const result = await folderTraversalService.traverseFolder(treeUri);
+
+ expect(result.files[0].name).toBe('notes: draft.txt');
+ });
+
+ test('when the URI uses a short SD-card UUID prefix, then it strips the prefix', async () => {
+ // SD card URIs use a short hex UUID like "1A2B-3C4D"
+ const sdUri =
+ 'content://com.android.externalstorage.documents/tree/1A2B-3C4D%3APictures/document/1A2B-3C4D%3APictures%2Ffoo.jpg';
+
+ mockReadDirectoryAsync.mockImplementation((calledUri: string) => {
+ if (calledUri === treeUri) return Promise.resolve([sdUri]);
+ return Promise.reject(new Error('Not a directory'));
+ });
+ mockGetInfoAsync.mockResolvedValue({ ...infoFile(sdUri, 300), name: undefined });
+
+ const result = await folderTraversalService.traverseFolder(treeUri);
+
+ expect(result.files[0].name).toBe('foo.jpg');
+ });
+ });
+
+ describe('Cancellation via AbortSignal', () => {
+ test('when the signal is aborted before traversal starts, then it throws an AbortError immediately', async () => {
+ const controller = new AbortController();
+ controller.abort();
+
+ mockReadDirectoryAsync.mockResolvedValue([safFileUri('file.txt')]);
+ mockGetInfoAsync.mockResolvedValue(infoFile(safFileUri('file.txt')));
+
+ await expect(folderTraversalService.traverseFolder(treeUri, controller.signal)).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ });
+
+ test('when the signal is aborted mid-traversal, then it stops processing remaining items', async () => {
+ const controller = new AbortController();
+ const uri1 = safFileUri('file1.txt');
+ const uri2 = safFileUri('file2.txt');
+
+ mockReadDirectoryAsync.mockImplementation((uri: string) => {
+ if (uri === treeUri) return Promise.resolve([uri1, uri2]);
+ return Promise.reject(new Error('Not a directory'));
+ });
+ mockGetInfoAsync.mockImplementation((uri: string) => {
+ if (uri === uri1) {
+ controller.abort();
+ return Promise.resolve(infoFile(uri1, 100));
+ }
+ return Promise.resolve(infoFile(uri, 200));
+ });
+
+ await expect(folderTraversalService.traverseFolder(treeUri, controller.signal)).rejects.toMatchObject({
+ name: 'AbortError',
+ });
+ // uri2 was never processed because abort was detected at the start of the second iteration
+ expect(mockGetInfoAsync).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('SAF directory detection via readDirectoryAsync', () => {
+ test('when getInfoAsync returns exists:false but readDirectoryAsync succeeds, then the item is treated as a directory', async () => {
+ const subDirUri = safDirUri('SubFolder');
+ const fileUri = safNestedUri('SubFolder', 'file.txt');
+
+ mockReadDirectoryAsync.mockImplementation((uri: string) => {
+ if (uri === treeUri) return Promise.resolve([subDirUri]);
+ if (uri === subDirUri) return Promise.resolve([fileUri]);
+ return Promise.reject(new Error('Not a directory'));
+ });
+ mockGetInfoAsync.mockImplementation((uri: string) => {
+ // Patch inactive: getInfoAsync claims the dir doesn't exist
+ if (uri === subDirUri) return Promise.resolve({ exists: false });
+ if (uri === fileUri) return Promise.resolve(infoFile(fileUri, 512));
+ });
+
+ const result = await folderTraversalService.traverseFolder(treeUri);
+
+ expect(result.dirs).toHaveLength(1);
+ expect(result.dirs[0].name).toBe('SubFolder');
+ expect(result.dirs[0].isDirectory).toBe(true);
+ expect(result.files).toHaveLength(1);
+ expect(result.files[0].name).toBe('file.txt');
+ expect(result.files[0].relativePath).toBe('SubFolder/file.txt');
+ });
+ });
});
describe('when the URI uses an unsupported scheme', () => {
- it('when traverseFolder is called, then it throws an unsupported scheme error', async () => {
+ test('when traverseFolder is called, then it throws an unsupported scheme error', async () => {
await expect(folderTraversalService.traverseFolder('ftp://example.com/folder')).rejects.toThrow(
'Unsupported URI scheme',
);
diff --git a/src/services/drive/folder/folderTraversal.service.ts b/src/services/drive/folder/folderTraversal.service.ts
index 0bb12a2c7..94b011568 100644
--- a/src/services/drive/folder/folderTraversal.service.ts
+++ b/src/services/drive/folder/folderTraversal.service.ts
@@ -3,6 +3,7 @@ import { getInfoAsync, StorageAccessFramework } from 'expo-file-system/legacy';
import { Alert } from 'react-native';
import strings from '../../../../assets/lang/strings';
import { FolderTree, FolderTreeNode } from '../../../types/drive/folderUpload';
+import { getNameFromSafUri, hasSafVolumePrefix } from './utils/safUri';
const MAX_FILES = 3000;
@@ -20,13 +21,6 @@ type TraverseContext = {
fileCount: number;
};
-type SAFTraverseContext = {
- dirUri: string;
- currentRelativePath: string;
- result: FolderTree;
- fileCount: number;
-};
-
const traverseFileUri = async ({ dirPath, rootPath, result, fileCount }: TraverseContext): Promise => {
const items = await RNFS.readDir(dirPath);
@@ -63,41 +57,38 @@ const traverseFileUri = async ({ dirPath, rootPath, result, fileCount }: Travers
return fileCount;
};
-const getNameFromSafUri = (childUri: string): string => {
- const documentPart = childUri.split('/document/').pop() ?? '';
- const decoded = decodeURIComponent(documentPart);
- return decoded.split('/').pop() ?? decoded;
-};
-
-const traverseSafUri = async ({
- dirUri,
- currentRelativePath,
- result,
- fileCount,
-}: SAFTraverseContext): Promise => {
- const childUris = await StorageAccessFramework.readDirectoryAsync(dirUri);
-
+/**
+ * Traverses SAF child URIs recursively.
+ *
+ * Each child gets exactly one readDirectoryAsync call:
+ * - Resolves → directory; the returned list is reused directly for recursion.
+ * - Throws → file.
+ *
+ * Checks signal.aborted before each item so cancellation takes effect within one loop tick.
+ */
+const traverseSafChildren = async (
+ childUris: string[],
+ currentRelativePath: string,
+ result: FolderTree,
+ fileCount: number,
+ signal?: AbortSignal,
+): Promise => {
for (const childUri of childUris) {
- const info = await getInfoAsync(childUri);
+ if (signal?.aborted) throw new DOMException('Folder scan cancelled', 'AbortError');
+
+ const info = await getInfoAsync(childUri).catch(() => ({ exists: false as const }));
const infoName = (info as { name?: string }).name;
- const name = infoName || getNameFromSafUri(childUri);
- const isDirectory = info.exists ? info.isDirectory : false;
+ const useInfoName = infoName != null && !hasSafVolumePrefix(infoName);
+ const name = useInfoName ? infoName : getNameFromSafUri(childUri);
+
const relativePath = currentRelativePath ? `${currentRelativePath}/${name}` : name;
const parentPath = currentRelativePath;
- const node: FolderTreeNode = {
- relativePath,
- parentPath,
- name,
- isDirectory,
- size: isDirectory || !info.exists ? 0 : info.size,
- uri: childUri,
- };
-
- if (isDirectory) {
- result.dirs.push(node);
- fileCount = await traverseSafUri({ dirUri: childUri, currentRelativePath: relativePath, result, fileCount });
- } else {
+ let grandchildUris: string[];
+ try {
+ grandchildUris = await StorageAccessFramework.readDirectoryAsync(childUri);
+ } catch {
+ const size = (info as { size?: number }).size ?? 0;
fileCount++;
if (fileCount > MAX_FILES) {
Alert.alert(
@@ -106,8 +97,12 @@ const traverseSafUri = async ({
);
throw new FolderTooLargeError();
}
- result.files.push(node);
+ result.files.push({ relativePath, parentPath, name, isDirectory: false, size, uri: childUri });
+ continue;
}
+
+ result.dirs.push({ relativePath, parentPath, name, isDirectory: true, size: 0, uri: childUri });
+ fileCount = await traverseSafChildren(grandchildUris, relativePath, result, fileCount, signal);
}
return fileCount;
@@ -122,7 +117,7 @@ const traverseSafUri = async ({
* @throws {FolderTooLargeError} When the folder contains more than {@link MAX_FILES} files.
* @throws {Error} When the URI scheme is not supported.
*/
-const traverseFolder = async (uri: string): Promise => {
+const traverseFolder = async (uri: string, signal?: AbortSignal): Promise => {
if (uri.startsWith('file://')) {
const rootPath = decodeURIComponent(uri.replace('file://', '').replace(/\/$/, ''));
const result: FolderTree = { dirs: [], files: [] };
@@ -132,7 +127,8 @@ const traverseFolder = async (uri: string): Promise => {
if (uri.startsWith('content://')) {
const result: FolderTree = { dirs: [], files: [] };
- await traverseSafUri({ dirUri: uri, currentRelativePath: '', result, fileCount: 0 });
+ const rootChildUris = await StorageAccessFramework.readDirectoryAsync(uri);
+ await traverseSafChildren(rootChildUris, '', result, 0, signal);
return result;
}
diff --git a/src/services/drive/folder/folderUpload.service.ts b/src/services/drive/folder/folderUpload.service.ts
index acbf19fe3..4a80cf940 100644
--- a/src/services/drive/folder/folderUpload.service.ts
+++ b/src/services/drive/folder/folderUpload.service.ts
@@ -1,6 +1,7 @@
import { logger } from '@internxt-mobile/services/common';
import { errorCodes, isErrorWithCode, pickDirectory } from '@react-native-documents/picker';
import strings from '../../../../assets/lang/strings';
+import { getSafTreeName } from './utils/safUri';
export interface PickedFolder {
uri: string;
@@ -16,9 +17,7 @@ const pickFolder = async (): Promise => {
const result = await pickDirectory({ requestLongTermAccess: false });
const uri = result.uri;
- const decoded = decodeURIComponent(uri);
- const segments = decoded.replace(/\/$/, '').split('/');
- const name = segments[segments.length - 1] || strings.generic.unnamedFolder;
+ const name = getSafTreeName(uri, strings.generic.unnamedFolder);
logger.info(`[folderUpload] Folder picked — uri: ${uri}, name: ${name}`);
diff --git a/src/services/drive/folder/utils/safUri.spec.ts b/src/services/drive/folder/utils/safUri.spec.ts
new file mode 100644
index 000000000..79038a1f2
--- /dev/null
+++ b/src/services/drive/folder/utils/safUri.spec.ts
@@ -0,0 +1,101 @@
+import { getNameFromSafUri, getSafTreeName, hasSafVolumePrefix } from './safUri';
+
+const tree = 'content://com.android.externalstorage.documents/tree/primary%3ADownload';
+const childUri = (encoded: string) => `${tree}/document/${encoded}`;
+
+describe('hasSafVolumePrefix', () => {
+ test.each([
+ ['primary:DCIM', 'primary'],
+ ['secondary:folder', 'secondary'],
+ ['home:Documents', 'home'],
+ ['downloads:file.pdf', 'downloads'],
+ ['raw:/absolute/path', 'raw'],
+ ['1A2B-3C4D:Pictures', 'short SD-card UUID'],
+ ['550e8400-e29b-41d4-a716-446655440000:folder', 'long UUID'],
+ ])('when the input is "%s" (%s), then it returns true', (input) => {
+ expect(hasSafVolumePrefix(input)).toBe(true);
+ });
+
+ test.each([
+ ['notes: draft.txt', 'colon not at start'],
+ ['my-folder', 'no colon'],
+ ['IMG_20260417.jpeg', 'plain filename'],
+ ['', 'empty string'],
+ ])('when the input is "%s" (%s), then it returns false', (input) => {
+ expect(hasSafVolumePrefix(input)).toBe(false);
+ });
+});
+
+describe('getNameFromSafUri', () => {
+ test('when the URI contains a simple filename, then it returns the filename', () => {
+ const uri = childUri('primary%3ADownload%2Fphoto.jpg');
+ expect(getNameFromSafUri(uri)).toBe('photo.jpg');
+ });
+
+ test('when the URI contains a nested path, then it returns only the last segment', () => {
+ const uri = childUri('primary%3ADownload%2FSubFolder%2Fdeep.txt');
+ expect(getNameFromSafUri(uri)).toBe('deep.txt');
+ });
+
+ test('when the URI points to a folder with no trailing filename, then it returns the folder name', () => {
+ const uri = childUri('primary%3ADownload%2FRecordings');
+ expect(getNameFromSafUri(uri)).toBe('Recordings');
+ });
+
+ test('when the name contains percent-encoded spaces, then it decodes them', () => {
+ const uri = childUri('primary%3ADownload%2FMy%20Files');
+ expect(getNameFromSafUri(uri)).toBe('My Files');
+ });
+
+ test('when the document segment has no slash after the volume prefix, then it strips the prefix', () => {
+ const uri = childUri('primary%3ADCIM');
+ expect(getNameFromSafUri(uri)).toBe('DCIM');
+ });
+
+ test('when the URI uses a short SD-card UUID prefix, then it strips the prefix', () => {
+ const uri =
+ 'content://com.android.externalstorage.documents/tree/1A2B-3C4D%3APictures/document/1A2B-3C4D%3APictures%2Ffoo.jpg';
+ expect(getNameFromSafUri(uri)).toBe('foo.jpg');
+ });
+
+ test('when the URI uses a long UUID prefix, then it strips the prefix', () => {
+ const uuid = '550e8400-e29b-41d4-a716-446655440000';
+ const uri = `content://com.android.externalstorage.documents/tree/${encodeURIComponent(uuid + ':Music')}/document/${encodeURIComponent(uuid + ':Music/song.mp3')}`;
+ expect(getNameFromSafUri(uri)).toBe('song.mp3');
+ });
+
+ test('when the name contains a colon that is not a volume prefix, then it preserves it', () => {
+ const uri = childUri(encodeURIComponent('primary:Download/notes: draft.txt'));
+ expect(getNameFromSafUri(uri)).toBe('notes: draft.txt');
+ });
+});
+
+describe('getSafTreeName', () => {
+ test('when the tree URI encodes a primary volume folder, then it returns the folder name without the prefix', () => {
+ expect(getSafTreeName('content://com.android.externalstorage.documents/tree/primary%3AMusic', 'Unnamed')).toBe(
+ 'Music',
+ );
+ });
+
+ test('when the tree URI has a trailing slash, then it strips it before extracting the name', () => {
+ expect(getSafTreeName('content://com.android.externalstorage.documents/tree/primary%3ADownload/', 'Unnamed')).toBe(
+ 'Download',
+ );
+ });
+
+ test('when the tree URI uses a short SD-card UUID prefix, then it strips the prefix', () => {
+ expect(getSafTreeName('content://com.android.externalstorage.documents/tree/1A2B-3C4D%3APictures', 'Unnamed')).toBe(
+ 'Pictures',
+ );
+ });
+
+ test('when stripping the prefix leaves an empty string, then it returns the fallback', () => {
+ expect(getSafTreeName('content://com.android.externalstorage.documents/tree/primary%3A', 'Unnamed')).toBe(
+ 'Unnamed',
+ );
+ });
+
+ test('when the URI segment has no volume prefix, then it returns the name as-is', () => {
+ expect(getSafTreeName('content://com.android.externalstorage.documents/tree/MyFolder', 'Unnamed')).toBe('MyFolder');
+ });
+});
diff --git a/src/services/drive/folder/utils/safUri.ts b/src/services/drive/folder/utils/safUri.ts
new file mode 100644
index 000000000..b444146b3
--- /dev/null
+++ b/src/services/drive/folder/utils/safUri.ts
@@ -0,0 +1,29 @@
+const SAF_WORD_VOLUME_RE = /^(primary|secondary|home|downloads|raw):/;
+const SAF_SHORT_UUID_RE = /^[0-9a-f]{4}-[0-9a-f]{4}:/i;
+const SAF_FULL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}:/i;
+
+export const hasSafVolumePrefix = (safUri: string): boolean =>
+ SAF_WORD_VOLUME_RE.test(safUri) || SAF_SHORT_UUID_RE.test(safUri) || SAF_FULL_UUID_RE.test(safUri);
+
+export const stripSafVolumePrefix = (safUri: string): string =>
+ safUri.replace(SAF_WORD_VOLUME_RE, '').replace(SAF_SHORT_UUID_RE, '').replace(SAF_FULL_UUID_RE, '');
+
+/**
+ * Extracts the display name from a SAF child document URI.
+ */
+export const getNameFromSafUri = (childUri: string): string => {
+ const documentPart = childUri.split('/document/').pop() ?? '';
+ const decoded = decodeURIComponent(documentPart);
+ const withoutVolume = stripSafVolumePrefix(decoded);
+ return withoutVolume.split('/').pop() || withoutVolume;
+};
+
+/**
+ * Extracts the display name from a SAF tree root URI returned by the directory picker.
+ *
+ */
+export const getSafTreeName = (treeUri: string, fallback: string): string => {
+ const decoded = decodeURIComponent(treeUri);
+ const rawName = decoded.replace(/\/$/, '').split('/').pop() ?? '';
+ return stripSafVolumePrefix(rawName) || fallback;
+};
diff --git a/src/shareExtension/ShareExtensionView.android.tsx b/src/shareExtension/ShareExtensionView.android.tsx
index d5d6b1da6..5d58acbdd 100644
--- a/src/shareExtension/ShareExtensionView.android.tsx
+++ b/src/shareExtension/ShareExtensionView.android.tsx
@@ -9,10 +9,11 @@ import { useShareExtension } from './hooks/useShareExtension.android';
import { useShareUpload } from './hooks/useShareUpload';
import { DriveScreen } from './screens/DriveScreen';
import { NotSignedInScreen } from './screens/NotSignedInScreen';
-import { colors } from './theme';
+import { useShareColors } from './theme';
const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'AndroidShare'>) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const { status, rootFolderUuid, sharedFiles, mnemonic, bucket, bridgeUser, userId } = useShareExtension(
route.params?.files ?? [],
);
@@ -22,6 +23,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
uploadError,
progress: uploadProgress,
thumbnailUri,
+ uploadedCount,
collisionState,
uploadFiles,
handleCollisionAction,
@@ -51,7 +53,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
if (status === 'unauthenticated') {
return (
-
+
);
@@ -59,7 +61,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
if (status === 'loading' || !rootFolderUuid) {
return (
-
+
@@ -68,7 +70,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
}
return (
-
+ {
const tailwind = useTailwind();
+ const colors = useShareColors();
const { sdkReady, sharedFiles } = useShareExtension({
photosToken,
mnemonic,
@@ -51,6 +55,7 @@ const ShareExtensionView = ({
uploadError,
progress: uploadProgress,
thumbnailUri,
+ uploadedCount,
collisionState,
uploadFiles,
handleCollisionAction,
@@ -70,35 +75,37 @@ const ShareExtensionView = ({
close();
}, []);
- if (!photosToken) {
- return openHostApp(AppPaths.signIn())} />;
- }
-
- if (!sdkReady || !rootFolderId) {
- return (
-
+ let content;
+ if (photosToken && sdkReady && rootFolderId) {
+ content = (
+
+ );
+ } else if (photosToken) {
+ content = (
+
);
+ } else {
+ content = openHostApp(AppPaths.signIn())} />;
}
- return (
-
- );
+ return {content};
};
export default ShareExtensionView;
diff --git a/src/shareExtension/ShareThemeProvider.tsx b/src/shareExtension/ShareThemeProvider.tsx
new file mode 100644
index 000000000..270bf88bd
--- /dev/null
+++ b/src/shareExtension/ShareThemeProvider.tsx
@@ -0,0 +1,16 @@
+import { createContext, useContext, type ReactNode } from 'react';
+
+export type ThemePreference = 'light' | 'dark' | null | undefined;
+
+export const ShareThemeContext = createContext(undefined);
+
+interface ShareThemeProviderProps {
+ themePreference: ThemePreference;
+ children: ReactNode;
+}
+
+export const ShareThemeProvider = ({ themePreference, children }: ShareThemeProviderProps) => (
+ {children}
+);
+
+export const useShareThemeContext = (): ThemePreference => useContext(ShareThemeContext);
diff --git a/src/shareExtension/components/BottomFilePanel.tsx b/src/shareExtension/components/BottomFilePanel.tsx
index 66a0f3c16..87fce6242 100644
--- a/src/shareExtension/components/BottomFilePanel.tsx
+++ b/src/shareExtension/components/BottomFilePanel.tsx
@@ -15,7 +15,7 @@ import StackedFilesIconSvg from '../../../assets/icons/stacked-files.svg';
import strings from '../../../assets/lang/strings';
import { getFileTypeIcon } from '../../helpers/filetypes';
import { useBottomPanelAnimation } from '../hooks/useBottomPanelAnimation';
-import { colors, fontStyles } from '../theme';
+import { fontStyles, useShareColors } from '../theme';
import { SharedFile } from '../types';
import { formatBytes, getSharedFileExtension } from '../utils';
import { TextButton } from './TextButton';
@@ -51,6 +51,7 @@ export const BottomFilePanel = ({
onEndRename,
}: BottomFilePanelProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const { width: screenWidth } = useWindowDimensions();
const { isCollapsed, keyboardBottom, slideAnimation, toggle } = useBottomPanelAnimation(isRenaming, screenWidth);
@@ -61,7 +62,16 @@ export const BottomFilePanel = ({
return hasAnySize ? sum : null;
}, [sharedFiles]);
- const containerStyle = useMemo(() => [tailwind('flex-row items-center'), panelStyles.container], [tailwind]);
+ const containerStyle = useMemo(
+ () => [
+ styles.containerBase,
+ {
+ backgroundColor: colors.surface,
+ borderColor: colors.gray10,
+ },
+ ],
+ [colors],
+ );
const file = sharedFiles[0];
const originalFileName = file?.fileName ?? '';
@@ -83,7 +93,7 @@ export const BottomFilePanel = ({
const collapseButton = (
{isCollapsed ? (
@@ -94,20 +104,22 @@ export const BottomFilePanel = ({
);
+ const divider = ;
+
const animatedStyle = { bottom: keyboardBottom, transform: [{ translateX: slideAnimation }] };
if (sharedFiles.length > 1) {
return (
-
+
{collapseButton}
-
+ {divider}
-
+
{strings.formatString(strings.screens.ShareExtension.itemsSelected, sharedFiles.length)}
{totalSize !== null || formats ? (
-
+
{[totalSize === null ? null : formatBytes(totalSize), formats || null].filter(Boolean).join(' · ')}
) : null}
@@ -123,12 +135,12 @@ export const BottomFilePanel = ({
const isImage = file.mimeType?.startsWith('image/') ?? false;
return (
-
+
{collapseButton}
-
+ {divider}
{isImage ? (
-
+
) : (
)}
@@ -136,7 +148,7 @@ export const BottomFilePanel = ({
{isRenaming ? (
) : (
{displayName}
)}
-
+
{file.size === null ? ext : `${formatBytes(file.size)} · ${ext}`}
@@ -162,8 +174,8 @@ export const BottomFilePanel = ({
);
};
-const panelStyles = StyleSheet.create({
- container: {
+const styles = StyleSheet.create({
+ containerBase: {
position: 'absolute',
marginHorizontal: PANEL_MARGIN,
paddingHorizontal: PANEL_MARGIN,
@@ -171,8 +183,6 @@ const panelStyles = StyleSheet.create({
minHeight: 64,
borderRadius: 20,
borderWidth: 1,
- borderColor: colors.gray10,
- backgroundColor: colors.surface,
shadowColor: '#000',
shadowOffset: { width: 0, height: 32 },
shadowOpacity: 0.04,
@@ -183,10 +193,9 @@ const panelStyles = StyleSheet.create({
width: TAB_WIDTH,
alignSelf: 'stretch',
},
- divider: {
+ dividerBase: {
width: 1,
alignSelf: 'stretch',
- backgroundColor: colors.gray10,
marginRight: 12,
},
fileImage: {
@@ -197,9 +206,8 @@ const panelStyles = StyleSheet.create({
fileNameText: {
paddingRight: 8,
},
- renameInput: {
+ renameInputBase: {
padding: 0,
borderBottomWidth: 1,
- borderBottomColor: colors.primary,
},
});
diff --git a/src/shareExtension/components/DriveHeader.tsx b/src/shareExtension/components/DriveHeader.tsx
index 2a39a9eae..2563e8fd5 100644
--- a/src/shareExtension/components/DriveHeader.tsx
+++ b/src/shareExtension/components/DriveHeader.tsx
@@ -1,8 +1,8 @@
import { XIcon } from 'phosphor-react-native';
-import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native';
+import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../assets/lang/strings';
-import { colors, fontStyles } from '../theme';
+import { fontStyles, useShareColors } from '../theme';
interface DriveHeaderProps {
onClose: () => void;
@@ -13,27 +13,24 @@ interface DriveHeaderProps {
export const DriveHeader = ({ onClose, onSave, saveEnabled, isSaveLoading }: DriveHeaderProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
return (
-
+
@@ -43,18 +40,15 @@ export const DriveHeader = ({ onClose, onSave, saveEnabled, isSaveLoading }: Dri
-
+
{strings.buttons.save}
- {isSaveLoading && }
+ {isSaveLoading && }
);
diff --git a/src/shareExtension/components/DriveScreen/DriveList.tsx b/src/shareExtension/components/DriveScreen/DriveList.tsx
index 0ad62fddc..3e0efb320 100644
--- a/src/shareExtension/components/DriveScreen/DriveList.tsx
+++ b/src/shareExtension/components/DriveScreen/DriveList.tsx
@@ -3,7 +3,7 @@ import { FlatList, Keyboard, Text, View } from 'react-native';
import DriveItemSkinSkeleton from 'src/components/DriveItemSkinSkeleton';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../../assets/lang/strings';
-import { fontStyles } from '../../theme';
+import { fontStyles, useShareColors } from '../../theme';
import { DriveViewMode, ShareFileItem, ShareFolderItem } from '../../types';
import { FileListItem } from '../FileListItem';
@@ -33,6 +33,7 @@ export const DriveList = ({
onLoadMore,
}: DriveListProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const numColumns = viewMode === 'grid' ? 3 : 1;
const renderItem = useCallback(
@@ -66,7 +67,7 @@ export const DriveList = ({
contentContainerStyle={viewMode === 'grid' ? tailwind('px-2') : undefined}
ListEmptyComponent={
-
+
{searchQuery ? strings.screens.ShareExtension.noResults : strings.screens.ShareExtension.emptyFolder}
diff --git a/src/shareExtension/components/DriveScreen/RootHeader.tsx b/src/shareExtension/components/DriveScreen/RootHeader.tsx
index ed7574c35..0c450b086 100644
--- a/src/shareExtension/components/DriveScreen/RootHeader.tsx
+++ b/src/shareExtension/components/DriveScreen/RootHeader.tsx
@@ -2,7 +2,7 @@ import { MagnifyingGlassIcon } from 'phosphor-react-native';
import { Text, TextInput, View } from 'react-native';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../../assets/lang/strings';
-import { colors, fontStyles } from '../../theme';
+import { fontStyles, useShareColors } from '../../theme';
import { TextButton } from '../TextButton';
interface RootHeaderProps {
@@ -13,20 +13,21 @@ interface RootHeaderProps {
export const RootHeader = ({ searchQuery, onChangeSearch, onNewFolder }: RootHeaderProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
return (
<>
-
+
{strings.screens.ShareExtension.rootFolderName}
-
+ {
const tailwind = useTailwind();
+ const colors = useShareColors();
return (
-
+
{strings.screens.ShareExtension.sortByName}
diff --git a/src/shareExtension/components/DriveScreen/SubfolderHeader.tsx b/src/shareExtension/components/DriveScreen/SubfolderHeader.tsx
index cb0c13ad0..80dc08a87 100644
--- a/src/shareExtension/components/DriveScreen/SubfolderHeader.tsx
+++ b/src/shareExtension/components/DriveScreen/SubfolderHeader.tsx
@@ -3,7 +3,7 @@ import { Animated, Text, TextInput, TouchableOpacity, View } from 'react-native'
import { useTailwind } from 'tailwind-rn';
import strings from '../../../../assets/lang/strings';
import { UseSearchAnimationResult } from '../../hooks/useSearchAnimation';
-import { colors, fontStyles } from '../../theme';
+import { fontStyles, useShareColors } from '../../theme';
import { TextButton } from '../TextButton';
interface SubfolderHeaderProps {
@@ -28,6 +28,7 @@ export const SubfolderHeader = ({
searchAnim,
}: SubfolderHeaderProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const { searchHeight, searchOpacity, isSearchOpen, searchRef, toggleSearch } = searchAnim;
const handleSearchPress = () => toggleSearch(onClearSearch);
@@ -39,8 +40,9 @@ export const SubfolderHeader = ({
@@ -53,13 +55,13 @@ export const SubfolderHeader = ({
-
+
diff --git a/src/shareExtension/components/FileListItem.tsx b/src/shareExtension/components/FileListItem.tsx
index e8b82de18..f4b9d0e8f 100644
--- a/src/shareExtension/components/FileListItem.tsx
+++ b/src/shareExtension/components/FileListItem.tsx
@@ -1,7 +1,7 @@
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useTailwind } from 'tailwind-rn';
import { FolderIcon, getFileTypeIcon } from '../../helpers/filetypes';
-import { colors, fontStyles } from '../theme';
+import { fontStyles, useShareColors } from '../theme';
import { DriveViewMode, ShareFileItem, ShareFolderItem } from '../types';
import { formatBytes, formatDate } from '../utils';
@@ -14,6 +14,7 @@ interface FileListItemProps {
export const FileListItem = ({ item, isFolder, viewMode, onPress }: FileListItemProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const IconComponent = isFolder ? FolderIcon : getFileTypeIcon((item as ShareFileItem).type ?? '');
const fileItem = isFolder ? null : (item as ShareFileItem);
const fileSize = fileItem ? Number.parseInt(fileItem.size, 10) : Number.NaN;
@@ -32,10 +33,10 @@ export const FileListItem = ({ item, isFolder, viewMode, onPress }: FileListItem
-
+
{displayName}
-
+
{formatDate(item.updatedAt)}
@@ -53,14 +54,14 @@ export const FileListItem = ({ item, isFolder, viewMode, onPress }: FileListItem
-
+
{displayName}
-
+
{fileItem ? `${fileSizeText} · ${formatDate(item.updatedAt)}` : formatDate(item.updatedAt)}
-
+
);
};
@@ -78,6 +79,5 @@ const styles = StyleSheet.create({
left: 16,
right: 16,
height: StyleSheet.hairlineWidth,
- backgroundColor: colors.gray10,
},
});
diff --git a/src/shareExtension/components/NewFolderModal.tsx b/src/shareExtension/components/NewFolderModal.tsx
index add87e901..d10d9fcf4 100644
--- a/src/shareExtension/components/NewFolderModal.tsx
+++ b/src/shareExtension/components/NewFolderModal.tsx
@@ -2,7 +2,8 @@ import { useCallback, useState } from 'react';
import { ActivityIndicator, Modal, Pressable, Text, TextInput, TouchableHighlight, View } from 'react-native';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../assets/lang/strings';
-import { colors, fontStyles } from '../theme';
+import { HTTP_CONFLICT } from '../../services/common';
+import { fontStyles, useShareColors } from '../theme';
interface NewFolderModalProps {
visible: boolean;
@@ -12,6 +13,7 @@ interface NewFolderModalProps {
export const NewFolderModal = ({ visible, onCancel, onCreate }: NewFolderModalProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const [name, setName] = useState(strings.screens.create_folder.defaultName);
const [focused, setFocused] = useState(false);
const [loading, setLoading] = useState(false);
@@ -29,8 +31,12 @@ export const NewFolderModal = ({ visible, onCancel, onCreate }: NewFolderModalPr
await onCreate(trimmed);
setName(strings.screens.create_folder.defaultName);
onCancel();
- } catch {
- setError(strings.screens.ShareExtension.folderCreateError);
+ } catch (error) {
+ setError(
+ (error as { status: number }).status === HTTP_CONFLICT
+ ? strings.screens.ShareExtension.folderAlreadyExists
+ : strings.screens.ShareExtension.folderCreateError,
+ );
} finally {
setLoading(false);
}
@@ -50,28 +56,37 @@ export const NewFolderModal = ({ visible, onCancel, onCreate }: NewFolderModalPr
const handleFocus = useCallback(() => setFocused(true), []);
const handleBlur = useCallback(() => setFocused(false), []);
+ const inputBorderColor = () => {
+ if (error) return colors.red;
+ if (focused) return colors.primary;
+ return colors.gray20;
+ };
+
return (
-
- {strings.buttons.newFolder}
+
+
+ {strings.buttons.newFolder}
+
- {strings.screens.ShareExtension.folderNameLabel}
+
+ {strings.screens.ShareExtension.folderNameLabel}
+
- {error ? {error} : null}
+ {error ? {error} : null}
{loading ? (
-
+
) : (
- {strings.buttons.create}
+
+ {strings.buttons.create}
+
)}
-
+
);
diff --git a/src/shareExtension/components/NotificationLabel.tsx b/src/shareExtension/components/NotificationLabel.tsx
new file mode 100644
index 000000000..b620fb5bc
--- /dev/null
+++ b/src/shareExtension/components/NotificationLabel.tsx
@@ -0,0 +1,109 @@
+import { CheckCircleIcon, WarningCircleIcon, XIcon } from 'phosphor-react-native';
+import { useEffect, useRef, useState } from 'react';
+import { Animated, Text, TouchableOpacity, View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import { fontStyles, useShareColors } from '../theme';
+
+type NotificationType = 'success' | 'error';
+
+interface NotificationLabelProps {
+ visible: boolean;
+ type: NotificationType;
+ message: string;
+ actionLabel?: string;
+ onAction?: () => void;
+ onDismiss: () => void;
+ dismissAfter?: number;
+}
+
+export const NotificationLabel = ({
+ visible,
+ type,
+ message,
+ actionLabel,
+ onAction,
+ onDismiss,
+ dismissAfter,
+}: NotificationLabelProps) => {
+ const tailwind = useTailwind();
+ const colors = useShareColors();
+ const translateY = useRef(new Animated.Value(80)).current;
+ const [shouldRender, setShouldRender] = useState(visible);
+
+ useEffect(() => {
+ if (!visible || !dismissAfter) return;
+ const timer = setTimeout(onDismiss, dismissAfter);
+ return () => clearTimeout(timer);
+ }, [visible, dismissAfter, onDismiss]);
+
+ useEffect(() => {
+ if (visible) {
+ setShouldRender(true);
+ Animated.spring(translateY, {
+ toValue: 0,
+ useNativeDriver: true,
+ damping: 20,
+ stiffness: 200,
+ }).start();
+ } else {
+ Animated.timing(translateY, {
+ toValue: 80,
+ duration: 200,
+ useNativeDriver: true,
+ }).start(() => setShouldRender(false));
+ }
+ }, [visible, translateY]);
+
+ if (!shouldRender) return null;
+
+ const isSuccess = type === 'success';
+ const iconColor = isSuccess ? colors.successGreen : colors.red;
+ const Icon = isSuccess ? CheckCircleIcon : WarningCircleIcon;
+ const hasAction = isSuccess && !!actionLabel && !!onAction;
+
+ let actionComponent;
+ if (hasAction) {
+ actionComponent = (
+
+ {actionLabel}
+
+ );
+ } else {
+ actionComponent = (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {message}
+
+ {actionComponent}
+
+
+ );
+};
diff --git a/src/shareExtension/components/ShareNameCollisionModal.tsx b/src/shareExtension/components/ShareNameCollisionModal.tsx
index 50cf50071..ab33ab620 100644
--- a/src/shareExtension/components/ShareNameCollisionModal.tsx
+++ b/src/shareExtension/components/ShareNameCollisionModal.tsx
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { Modal, Pressable, Text, TouchableHighlight, View } from 'react-native';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../assets/lang/strings';
-import { colors, fontStyles } from '../theme';
+import { fontStyles, useShareColors } from '../theme';
import { NameCollisionAction } from '../types';
interface ShareNameCollisionModalProps {
@@ -21,6 +21,7 @@ export const ShareNameCollisionModal = ({
onCancel,
}: ShareNameCollisionModalProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const [selectedAction, setSelectedAction] = useState('replace');
const isMultiple = collisionedFilesCounter > 1;
@@ -41,27 +42,30 @@ export const ShareNameCollisionModal = ({
style={[tailwind('flex-1 items-center justify-center px-5'), { backgroundColor: 'rgba(0,0,0,0.4)' }]}
onPress={onCancel}
>
-
- {title}
- {message}
+
+ {title}
+ {message} setSelectedAction('replace')}
+ colors={colors}
/>
setSelectedAction('keep-both')}
+ colors={colors}
/>
- {strings.buttons.upload}
+ {strings.buttons.upload}
@@ -90,9 +94,10 @@ interface RadioOptionProps {
label: string;
selected: boolean;
onPress: () => void;
+ colors: ReturnType;
}
-const RadioOption = ({ label, selected, onPress }: RadioOptionProps) => {
+const RadioOption = ({ label, selected, onPress, colors }: RadioOptionProps) => {
const tailwind = useTailwind();
return (
diff --git a/src/shareExtension/components/UploadFeedback.tsx b/src/shareExtension/components/UploadFeedback.tsx
index 15bf5804c..fb80b00fa 100644
--- a/src/shareExtension/components/UploadFeedback.tsx
+++ b/src/shareExtension/components/UploadFeedback.tsx
@@ -1,9 +1,9 @@
import { WarningCircleIcon, XIcon } from 'phosphor-react-native';
import { useEffect, useRef } from 'react';
-import { ActivityIndicator, Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import { ActivityIndicator, Animated, Text, TouchableOpacity, View } from 'react-native';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../assets/lang/strings';
-import { colors, fontStyles } from '../theme';
+import { fontStyles, useShareColors } from '../theme';
import { UploadProgress, UploadStatus } from '../types';
import { formatBytes } from '../utils';
@@ -18,6 +18,7 @@ const PROGRESS_UPDATE_MIN_DELTA = 0.01;
export const UploadFeedback = ({ status, errorMessage, progress, onDismissError }: UploadFeedbackProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const progressBarAnimation = useRef(new Animated.Value(0)).current;
const previousFileIndexRef = useRef(0);
const previousProgressPercentRef = useRef(0);
@@ -57,26 +58,24 @@ export const UploadFeedback = ({ status, errorMessage, progress, onDismissError
const isPreparingUpload = shouldShowBytesProgress && progress.bytesUploaded === 0;
return (
-
+
-
+
{shouldShowFileCounter
? `${progress.currentFile} / ${progress.totalFiles}`
: strings.screens.ShareExtension.uploading}
-
+
{shouldShowBytesProgress && (
-
+
{isPreparingUpload
? strings.screens.ShareExtension.preparing
: `${formatBytes(progress.bytesUploaded)} / ${formatBytes(progress.currentFileSize)}`}
@@ -89,9 +88,14 @@ export const UploadFeedback = ({ status, errorMessage, progress, onDismissError
if (status === 'error') {
return (
-
+
- {errorMessage}
+ {errorMessage}
{onDismissError && (
@@ -103,20 +107,3 @@ export const UploadFeedback = ({ status, errorMessage, progress, onDismissError
return null;
};
-
-const styles = StyleSheet.create({
- uploadingBg: {
- backgroundColor: colors.primaryBg,
- },
- progressBar: {
- backgroundColor: colors.primaryBgStrong,
- },
- errorBg: {
- backgroundColor: colors.redBg,
- borderColor: colors.redBorder,
- borderWidth: 1,
- },
- errorText: {
- color: colors.gray100,
- },
-});
diff --git a/src/shareExtension/components/UploadSuccessCard.tsx b/src/shareExtension/components/UploadSuccessCard.tsx
index 129b75311..2c8020d4e 100644
--- a/src/shareExtension/components/UploadSuccessCard.tsx
+++ b/src/shareExtension/components/UploadSuccessCard.tsx
@@ -5,7 +5,7 @@ import { useTailwind } from 'tailwind-rn';
import StackedFilesIconSvg from '../../../assets/icons/stacked-files.svg';
import strings from '../../../assets/lang/strings';
import { getFileTypeIcon } from '../../helpers/filetypes';
-import { colors, fontStyles } from '../theme';
+import { fontStyles, useShareColors } from '../theme';
import { SharedFile } from '../types';
import { formatBytes, getSharedFileExtension } from '../utils';
@@ -13,6 +13,7 @@ interface UploadSuccessCardProps {
sharedFiles: SharedFile[];
uploadedFileName: string;
thumbnailUri?: string | null;
+ uploadedCount?: number;
onClose: () => void;
onViewInFolder: () => void;
}
@@ -21,10 +22,12 @@ export const UploadSuccessCard = ({
sharedFiles,
uploadedFileName,
thumbnailUri,
+ uploadedCount,
onClose,
onViewInFolder,
}: UploadSuccessCardProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const shareExtensionTrans = strings.screens.ShareExtension;
const slideAnim = useRef(new Animated.Value(400)).current;
@@ -37,7 +40,8 @@ export const UploadSuccessCard = ({
}).start();
}, [slideAnim]);
- const isSingleFile = sharedFiles.length === 1;
+ const displayCount = uploadedCount ?? sharedFiles.length;
+ const isSingleFile = displayCount === 1;
const firstFile = sharedFiles[0];
const fileExtension = firstFile ? getSharedFileExtension(firstFile) : '';
const isImage = firstFile?.mimeType?.startsWith('image/') ?? false;
@@ -51,7 +55,7 @@ export const UploadSuccessCard = ({
const fileName = isSingleFile
? uploadedFileName
- : strings.formatString(shareExtensionTrans.itemsUploaded, sharedFiles.length).toString();
+ : strings.formatString(shareExtensionTrans.itemsUploaded, displayCount).toString();
const formattedSize = totalSizeOrNull === null ? null : formatBytes(totalSizeOrNull);
const sizeAndFormat = isSingleFile
@@ -72,7 +76,8 @@ export const UploadSuccessCard = ({
@@ -81,23 +86,19 @@ export const UploadSuccessCard = ({
-
+
{renderFilePreview()}
{fileName}
{sizeAndFormat ? (
{sizeAndFormat}
@@ -106,30 +107,24 @@ export const UploadSuccessCard = ({
-
+
{shareExtensionTrans.uploadedTitle}
{shareExtensionTrans.uploadedSubtitle}
-
-
+
+
{shareExtensionTrans.viewInFolder}
diff --git a/src/shareExtension/errors.ts b/src/shareExtension/errors.ts
index d51ce195a..01562aaac 100644
--- a/src/shareExtension/errors.ts
+++ b/src/shareExtension/errors.ts
@@ -1,3 +1,5 @@
+import { EmptyFileNotAllowedError, isEmptyFilePlanError } from '../services/drive/file/utils/emptyFileErrors';
+
export class HttpUploadError extends Error {
constructor(
readonly status: number,
@@ -27,3 +29,5 @@ export class UploadNetworkError extends Error {
Object.setPrototypeOf(this, UploadNetworkError.prototype);
}
}
+
+export { EmptyFileNotAllowedError, isEmptyFilePlanError };
diff --git a/src/shareExtension/hooks/useShareUpload.ts b/src/shareExtension/hooks/useShareUpload.ts
index ea9062e23..a8ca275c3 100644
--- a/src/shareExtension/hooks/useShareUpload.ts
+++ b/src/shareExtension/hooks/useShareUpload.ts
@@ -1,7 +1,7 @@
import * as RNFS from '@dr.pogodin/react-native-fs';
import { useCallback, useRef, useState } from 'react';
import { NativeModules, Platform } from 'react-native';
-import { HttpUploadError, MissingFileUriError, UploadNetworkError } from '../errors';
+import { EmptyFileNotAllowedError, HttpUploadError, MissingFileUriError, UploadNetworkError } from '../errors';
import {
ShareUploadCredentials,
ShareUploadSession,
@@ -16,7 +16,7 @@ import {
UploadProgress,
UploadStatus,
} from '../types';
-import { getFileExtension, getFileNameWithoutExtension } from '../utils';
+import { getFileExtension, getFileNameWithoutExtension, toDisplayUri } from '../utils';
import { useNameCollision } from './useNameCollision';
interface PHAssetExportNativeModule {
@@ -35,6 +35,7 @@ const exportPhAsset = (phAssetId: string): Promise => PHAss
const HTTP_STATUS = {
UNAUTHORIZED: 401,
+ PAYMENT_REQUIRED: 402,
FORBIDDEN: 403,
CONFLICT: 409,
} as const;
@@ -49,6 +50,7 @@ interface UseShareUploadResult {
uploadError: unknown;
progress: UploadProgress | null;
thumbnailUri: string | null;
+ uploadedCount: number;
collisionState: CollisionState;
uploadFiles: (
files: SharedFile[],
@@ -67,9 +69,11 @@ interface FailedFile {
}
const classifyError = (error: unknown): UploadErrorType => {
+ if (error instanceof EmptyFileNotAllowedError) return 'payment_required';
if (error instanceof HttpUploadError) {
if (error.status === HTTP_STATUS.UNAUTHORIZED || error.status === HTTP_STATUS.FORBIDDEN) return 'session_expired';
if (error.status === HTTP_STATUS.CONFLICT) return 'file_already_exists';
+ if (error.status === HTTP_STATUS.PAYMENT_REQUIRED) return 'payment_required';
}
if (error instanceof MissingFileUriError) return 'prep_failed';
if (error instanceof UploadNetworkError) return 'no_internet';
@@ -161,6 +165,7 @@ export const useShareUpload = ({ onFileUploaded }: UseShareUploadOptions = {}):
const [uploadError, setUploadError] = useState(null);
const [progress, setProgress] = useState(null);
const [thumbnailUri, setThumbnailUri] = useState(null);
+ const [uploadedCount, setUploadedCount] = useState(0);
const thumbnailUriRef = useRef(null);
const { collisionState, handleCollisionAction, resetCollisionState, resolveCollisions } = useNameCollision();
@@ -175,6 +180,7 @@ export const useShareUpload = ({ onFileUploaded }: UseShareUploadOptions = {}):
setUploadError(null);
setProgress(null);
setThumbnailUri(null);
+ setUploadedCount(0);
resetCollisionState();
}, [resetCollisionState]);
@@ -200,6 +206,8 @@ export const useShareUpload = ({ onFileUploaded }: UseShareUploadOptions = {}):
setThumbnailUri(null);
const failedFiles: FailedFile[] = [];
+ let actualUploadedCount = 0;
+ let skippedPaymentRequired = 0;
for (let i = 0; i < files.length; i++) {
try {
@@ -220,19 +228,35 @@ export const useShareUpload = ({ onFileUploaded }: UseShareUploadOptions = {}):
onFileUploaded,
},
);
+ actualUploadedCount++;
if (isSingleFile) {
thumbnailUriRef.current = thumbnailLocalUri;
- setThumbnailUri(thumbnailLocalUri);
+ setThumbnailUri(thumbnailLocalUri ? toDisplayUri(thumbnailLocalUri) : null);
}
} catch (error) {
const uploadErrorType = classifyError(error);
+ const isPaymentRequiredFeature = uploadErrorType === 'payment_required';
+ const isMultiupload = files.length > 1;
+ if (isPaymentRequiredFeature && isMultiupload) {
+ skippedPaymentRequired++;
+ continue;
+ }
failedFiles.push({ index: i, errorType: uploadErrorType, uploadError: error });
if (uploadErrorType === 'session_expired') break;
}
}
+ setUploadedCount(actualUploadedCount);
+
if (failedFiles.length === 0) {
- setStatus('success');
+ if (actualUploadedCount === 0 && skippedPaymentRequired > 0) {
+ // All files were empty — treat as payment_required error
+ setErrorType('payment_required');
+ setUploadError(new EmptyFileNotAllowedError());
+ setStatus('error');
+ } else {
+ setStatus('success');
+ }
} else {
const { errorType: firstErrorType, uploadError: firstUploadError } = failedFiles[0];
setErrorType(firstErrorType);
@@ -255,6 +279,7 @@ export const useShareUpload = ({ onFileUploaded }: UseShareUploadOptions = {}):
uploadError,
progress,
thumbnailUri,
+ uploadedCount,
collisionState,
uploadFiles,
handleCollisionAction,
diff --git a/src/shareExtension/screens/DriveScreen.tsx b/src/shareExtension/screens/DriveScreen.tsx
index b433b8086..9cfcbb889 100644
--- a/src/shareExtension/screens/DriveScreen.tsx
+++ b/src/shareExtension/screens/DriveScreen.tsx
@@ -9,12 +9,14 @@ import { RootHeader } from '../components/DriveScreen/RootHeader';
import { SortRow } from '../components/DriveScreen/SortRow';
import { SubfolderHeader } from '../components/DriveScreen/SubfolderHeader';
import { NewFolderModal } from '../components/NewFolderModal';
+import { NotificationLabel } from '../components/NotificationLabel';
import { ShareNameCollisionModal } from '../components/ShareNameCollisionModal';
import { UploadFeedback } from '../components/UploadFeedback';
import { UploadSuccessCard } from '../components/UploadSuccessCard';
import { useFolderNavigation } from '../hooks/useFolderNavigation';
import { useNavAnimation } from '../hooks/useNavAnimation';
import { useSearchAnimation } from '../hooks/useSearchAnimation';
+import { useShareColors } from '../theme';
import {
CollisionState,
NameCollisionAction,
@@ -33,6 +35,7 @@ interface DriveScreenProps {
uploadError?: unknown;
uploadProgress?: UploadProgress | null;
thumbnailUri?: string | null;
+ uploadedCount?: number;
collisionState?: CollisionState;
onClose: () => void;
onSave: (destinationFolderUuid: string, renamedFileName?: string) => void;
@@ -49,6 +52,7 @@ export const DriveScreen = ({
uploadError,
uploadProgress,
thumbnailUri,
+ uploadedCount,
collisionState,
onClose,
onSave,
@@ -57,6 +61,7 @@ export const DriveScreen = ({
onCollisionAction,
}: DriveScreenProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const {
currentFolder,
folders,
@@ -76,6 +81,7 @@ export const DriveScreen = ({
const [showNewFolderModal, setShowNewFolderModal] = useState(false);
const [isRenaming, setIsRenaming] = useState(false);
const [finalName, setFinalName] = useState(sharedFiles[0]?.fileName ?? '');
+ const [folderCreatedToast, setFolderCreatedToast] = useState(false);
const isRoot = breadcrumb.length === 1;
const parentFolderIndex = breadcrumb.length - 2;
@@ -109,6 +115,7 @@ export const DriveScreen = ({
async (name: string) => {
await createFolder(name);
setShowNewFolderModal(false);
+ setFolderCreatedToast(true);
},
[createFolder],
);
@@ -130,11 +137,14 @@ export const DriveScreen = ({
const showUploadingBanner = isChecking || isUploading;
const showErrorBanner = uploadStatus === 'error';
- const ERROR_BANNER_BOTTOM = 112;
+ const NOTIFICATION_BOTTOM = 120;
return (
)}
-
+
{isRoot ? (
- {showErrorBanner && (
-
-
-
- )}
+
+
+ setFolderCreatedToast(false)}
+ dismissAfter={5000}
+ />
+
diff --git a/src/shareExtension/screens/NotSignedInScreen.tsx b/src/shareExtension/screens/NotSignedInScreen.tsx
index 501681212..38c0633b2 100644
--- a/src/shareExtension/screens/NotSignedInScreen.tsx
+++ b/src/shareExtension/screens/NotSignedInScreen.tsx
@@ -1,34 +1,33 @@
-import { Platform, TouchableOpacity, StyleSheet, Text, View } from 'react-native';
+import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../assets/lang/strings';
-import { colors, fontStyles } from '../theme';
+import { fontStyles, useShareColors } from '../theme';
interface NotSignedInScreenProps {
readonly onClose: () => void;
readonly onOpenLogin: () => void;
}
-function LoginIcon() {
- return (
-
- );
-}
+const LoginIcon = ({ color }: { color: string }) => (
+
+);
-export function NotSignedInScreen({ onClose, onOpenLogin }: NotSignedInScreenProps) {
+export const NotSignedInScreen = ({ onClose, onOpenLogin }: NotSignedInScreenProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const translations = strings.screens.ShareExtension;
return (
-
+
- ✕
+ ✕
- {translations.title}
+
+ {translations.title}
+
-
+
{translations.notSignedIn.subtitle}
{translations.notSignedIn.openLogin}
@@ -93,4 +101,4 @@ export function NotSignedInScreen({ onClose, onOpenLogin }: NotSignedInScreenPro
);
-}
+};
diff --git a/src/shareExtension/services/shareUploadService.ts b/src/shareExtension/services/shareUploadService.ts
index 2bf092024..7a45be6ae 100644
--- a/src/shareExtension/services/shareUploadService.ts
+++ b/src/shareExtension/services/shareUploadService.ts
@@ -9,7 +9,7 @@ import { Platform } from 'react-native';
import ReactNativeBlobUtil, { FetchBlobResponse } from 'react-native-blob-util';
import uuid from 'react-native-uuid';
import packageJson from '../../../package.json';
-import { HttpUploadError, UploadNetworkError } from '../errors';
+import { EmptyFileNotAllowedError, HttpUploadError, isEmptyFilePlanError, UploadNetworkError } from '../errors';
import {
buildSdkEncryptionAdapter,
computeRipemd160Digest,
@@ -172,7 +172,7 @@ const uploadEncryptedPart = async (
};
const createFileEntry = async (
- fileId: string,
+ fileId: string | undefined,
fileExtension: string,
fileSize: number,
fileName: string,
@@ -304,6 +304,21 @@ export interface ShareUploadFileResult {
thumbnailLocalUri: string | null;
}
+const uploadEmptyFile = async (
+ fileExtension: string,
+ fileName: string,
+ bucket: string,
+ folderUuid: string,
+): Promise => {
+ try {
+ await createFileEntry(undefined, fileExtension, 0, fileName, bucket, folderUuid);
+ return { thumbnailLocalUri: null };
+ } catch (err) {
+ if (isEmptyFilePlanError(err)) throw new EmptyFileNotAllowedError();
+ throw err;
+ }
+};
+
export const shareUploadFile = async (params: ShareUploadFileParams): Promise => {
const { filePath, fileName, fileExtension, folderUuid, credentials, shareUploadSession, onFileResolved, onProgress } =
params;
@@ -312,11 +327,20 @@ export const shareUploadFile = async (params: ShareUploadFileParams): Promise {
+ if (androidTempCopyPath) await RNFS.unlink(androidTempCopyPath).catch(() => undefined);
+ // On iOS the OS provides a sandboxed temp copy of the shared file; clean it up after upload.
+ if (Platform.OS === 'ios') await RNFS.unlink(localPath).catch(() => undefined);
+ };
- let thumbnailLocalUri: string | null = null;
try {
+ if (fileSize === 0) {
+ return await uploadEmptyFile(fileExtension, fileName, bucket, folderUuid);
+ }
+
+ const session = shareUploadSession ?? createShareUploadSession(credentials);
+ const { network, cryptoLib } = session;
+
const uploadContext: UploadFileContext = {
network,
cryptoLib,
@@ -330,13 +354,11 @@ export const shareUploadFile = async (params: ShareUploadFileParams): Promise= MULTIPART_THRESHOLD_BYTES) {
- uploadResult = await shareUploadMultipartFile(uploadContext);
- } else {
- uploadResult = await shareUploadSingleFile(uploadContext);
- }
+ const uploadResult = await (fileSize >= MULTIPART_THRESHOLD_BYTES
+ ? shareUploadMultipartFile(uploadContext)
+ : shareUploadSingleFile(uploadContext));
+ let thumbnailLocalUri: string | null = null;
try {
thumbnailLocalUri = await generateAndUploadThumbnail(
localPath,
@@ -349,11 +371,9 @@ export const shareUploadFile = async (params: ShareUploadFileParams): Promise undefined);
- // On iOS the OS provides a sandboxed temp copy of the shared file; clean it up after upload.
- if (Platform.OS === 'ios') await RNFS.unlink(localPath).catch(() => undefined);
+ await cleanup();
}
-
- return { thumbnailLocalUri };
};
diff --git a/src/shareExtension/theme.ts b/src/shareExtension/theme.ts
index c01f9c4c0..a97068a04 100644
--- a/src/shareExtension/theme.ts
+++ b/src/shareExtension/theme.ts
@@ -1,4 +1,5 @@
-import { Platform } from 'react-native';
+import { Platform, useColorScheme } from 'react-native';
+import { useShareThemeContext } from './ShareThemeProvider';
/**
* Used for icon colors, fonts, and values not expressed as tailwind classes (e.g. primaryDisabled).
@@ -21,7 +22,7 @@ export const fontStyles = {
},
} as const;
-export const colors = {
+const lightColors = {
primary: 'rgba(0,102,255)',
primaryDisabled: 'rgba(0,102,255,0.5)',
primaryBg: 'rgba(0,102,255,0.08)',
@@ -40,4 +41,58 @@ export const colors = {
gray5: 'rgba(243,243,248)',
gray1: 'rgba(249,249,252)',
surface: 'rgba(255,255,255)',
+ white: 'rgb(255,255,255)',
} as const;
+
+const darkColors = {
+ primary: 'rgb(20,114,255)',
+ primaryDisabled: 'rgba(20,114,255,0.5)',
+ primaryBg: 'rgba(20,114,255,0.15)',
+ primaryBgStrong: 'rgba(20,114,255,0.20)',
+ red: 'rgb(255,61,51)',
+ redBg: 'rgb(50,16,16)',
+ redBorder: 'rgb(90,28,24)',
+ successGreen: 'rgb(72,208,106)',
+ successBg: 'rgba(72,208,106,0.15)',
+ gray100: 'rgb(249,249,252)',
+ gray80: 'rgb(229,229,235)',
+ gray60: 'rgb(199,199,205)',
+ gray40: 'rgb(142,142,148)',
+ gray20: 'rgb(72,72,75)',
+ gray10: 'rgb(58,58,59)',
+ gray5: 'rgb(44,44,48)',
+ gray1: 'rgb(24,24,27)',
+ surface: 'rgb(17,17,17)',
+ white: 'rgb(255,255,255)',
+} as const;
+
+export interface ShareColors {
+ primary: string;
+ primaryDisabled: string;
+ primaryBg: string;
+ primaryBgStrong: string;
+ red: string;
+ redBg: string;
+ redBorder: string;
+ successGreen: string;
+ successBg: string;
+ gray100: string;
+ gray80: string;
+ gray60: string;
+ gray40: string;
+ gray20: string;
+ gray10: string;
+ gray5: string;
+ gray1: string;
+ surface: string;
+ white: string;
+}
+
+export const useShareColors = (): ShareColors => {
+ const themePreference = useShareThemeContext();
+ const systemScheme = useColorScheme();
+ const theme = themePreference ?? systemScheme;
+ return theme === 'dark' ? darkColors : lightColors;
+};
+
+export const colors = lightColors;
diff --git a/src/shareExtension/types.ts b/src/shareExtension/types.ts
index 235e44e79..1ae09740d 100644
--- a/src/shareExtension/types.ts
+++ b/src/shareExtension/types.ts
@@ -31,7 +31,8 @@ export type UploadErrorType =
| 'no_internet'
| 'session_expired'
| 'prep_failed'
- | 'file_already_exists';
+ | 'file_already_exists'
+ | 'payment_required';
export interface UploadProgress {
currentFile: number;
diff --git a/src/shareExtension/utils.spec.ts b/src/shareExtension/utils.spec.ts
new file mode 100644
index 000000000..d170bb7c1
--- /dev/null
+++ b/src/shareExtension/utils.spec.ts
@@ -0,0 +1,87 @@
+import { formatBytes, getFileExtension, getFileNameWithoutExtension, getMimeTypeFromUri, toDisplayUri } from './utils';
+
+describe('toDisplayUri', () => {
+ test('when given a raw filesystem path, then it prepends file://', () => {
+ expect(toDisplayUri('/data/user/0/com.internxt/cache/thumb.jpg')).toBe(
+ 'file:///data/user/0/com.internxt/cache/thumb.jpg',
+ );
+ });
+
+ test('when given a file:// URI, then it returns it unchanged', () => {
+ expect(toDisplayUri('file:///data/user/0/com.internxt/cache/thumb.jpg')).toBe(
+ 'file:///data/user/0/com.internxt/cache/thumb.jpg',
+ );
+ });
+
+ test('when given a content:// URI, then it returns it unchanged', () => {
+ expect(toDisplayUri('content://media/external/images/media/42')).toBe('content://media/external/images/media/42');
+ });
+
+ test('when given an https:// URI, then it returns it unchanged', () => {
+ expect(toDisplayUri('https://example.com/image.jpg')).toBe('https://example.com/image.jpg');
+ });
+
+ test('when given an http:// URI, then it returns it unchanged', () => {
+ expect(toDisplayUri('http://example.com/image.jpg')).toBe('http://example.com/image.jpg');
+ });
+});
+
+describe('getFileExtension', () => {
+ test('when given a filename with uppercase extension, then it returns it lowercased', () => {
+ expect(getFileExtension('photo.JPG')).toBe('jpg');
+ });
+
+ test('when given a filename with no extension, then it returns empty string', () => {
+ expect(getFileExtension('README')).toBe('');
+ });
+
+ test('when given null, then it returns empty string', () => {
+ expect(getFileExtension(null)).toBe('');
+ });
+
+ test('when given a dotted filename, then it returns the last extension', () => {
+ expect(getFileExtension('archive.tar.gz')).toBe('gz');
+ });
+
+ test('when given a hidden file (leading dot only), then it returns empty string', () => {
+ expect(getFileExtension('.gitignore')).toBe('');
+ });
+});
+
+describe('getFileNameWithoutExtension', () => {
+ test('when given a filename with extension, then it strips the extension', () => {
+ expect(getFileNameWithoutExtension('photo.jpg')).toBe('photo');
+ });
+
+ test('when given a filename with no extension, then it returns the full name', () => {
+ expect(getFileNameWithoutExtension('README')).toBe('README');
+ });
+
+ test('when given null, then it returns the fallback string', () => {
+ expect(getFileNameWithoutExtension(null)).toBe('file');
+ });
+
+ test('when given a dotted filename, then it strips only the last extension', () => {
+ expect(getFileNameWithoutExtension('archive.tar.gz')).toBe('archive.tar');
+ });
+});
+
+describe('getMimeTypeFromUri', () => {
+ test('when given a file:// URI with known extension, then it returns the MIME type', () => {
+ expect(getMimeTypeFromUri('file:///storage/emulated/0/DCIM/photo.jpg')).toBe('image/jpeg');
+ });
+
+ test('when given a content:// URI with a filename segment, then it returns the MIME type', () => {
+ expect(getMimeTypeFromUri('content://media/external/images/media/document.pdf')).toBe('application/pdf');
+ });
+
+ test('when given a URI with no recognizable extension, then it returns null', () => {
+ expect(getMimeTypeFromUri('content://media/external/images/media/42')).toBeNull();
+ });
+});
+
+describe('formatBytes', () => {
+ test('when given a byte count, then it returns a non-empty human-readable string', () => {
+ expect(formatBytes(1024)).toBeTruthy();
+ });
+});
diff --git a/src/shareExtension/utils.ts b/src/shareExtension/utils.ts
index f67e20dcb..19cc31fa7 100644
--- a/src/shareExtension/utils.ts
+++ b/src/shareExtension/utils.ts
@@ -9,6 +9,12 @@ import { SharedFile, UploadErrorType } from './types';
dayjs.extend(relativeTime);
+/**
+ * Normalizes a local filesystem path to a URI suitable for React Native's Image component.
+ */
+export const toDisplayUri = (path: string): string =>
+ path.startsWith('file://') || path.startsWith('content://') || path.startsWith('http') ? path : `file://${path}`;
+
export const formatDate = (dateStr: string): string => {
const date = dayjs(dateStr);
const weekDays = 7;
@@ -92,6 +98,8 @@ export const getUploadErrorMessage = (errorType: UploadErrorType | null, rawErro
return shareExtensionTexts.errorPrep;
case 'file_already_exists':
return shareExtensionTexts.errorFileAlreadyExists;
+ case 'payment_required':
+ return shareExtensionTexts.errorPaymentRequired;
case 'general':
return extractErrorMessage(rawError) ?? shareExtensionTexts.errorGeneral;
default:
diff --git a/src/store/slices/photos/index.spec.ts b/src/store/slices/photos/index.spec.ts
index 4bb4f444e..4ab2c30cf 100644
--- a/src/store/slices/photos/index.spec.ts
+++ b/src/store/slices/photos/index.spec.ts
@@ -3,6 +3,7 @@ import { configureStore } from '@reduxjs/toolkit';
import asyncStorageService from 'src/services/AsyncStorageService';
import { AppDispatch } from 'src/store';
import photosReducer, {
+ checkPermissionRevocationThunk,
disableBackupThunk,
enableBackupThunk,
hydratePhotosStateThunk,
@@ -161,6 +162,55 @@ describe('photos slice', () => {
expect(getPersistedState()).toMatchObject({ networkCondition: 'wifi-and-data' });
});
+ test('when checkPermissionRevocationThunk runs and backup is disabled, then nothing happens', async () => {
+ const store = makeStore();
+
+ await store.dispatch(checkPermissionRevocationThunk());
+
+ expect(mockPermissionService.getStatus).not.toHaveBeenCalled();
+ expect(store.getState().photos.enabled).toBe(false);
+ });
+
+ test('when checkPermissionRevocationThunk runs and backup is enabled and permission is denied, then backup is disabled', async () => {
+ mockPermissionService.requestPermission.mockResolvedValueOnce('granted');
+ mockPermissionService.getStatus.mockResolvedValueOnce('denied');
+
+ const store = makeStore();
+ await store.dispatch(enableBackupThunk());
+
+ await store.dispatch(checkPermissionRevocationThunk());
+
+ expect(store.getState().photos.enabled).toBe(false);
+ expect(store.getState().photos.permissionStatus).toBe('denied');
+ expect(getPersistedState()).toMatchObject({ enabled: false });
+ });
+
+ test('when checkPermissionRevocationThunk runs and backup is enabled and permission is still granted, then backup stays enabled', async () => {
+ mockPermissionService.requestPermission.mockResolvedValueOnce('granted');
+ mockPermissionService.getStatus.mockResolvedValueOnce('granted');
+
+ const store = makeStore();
+ await store.dispatch(enableBackupThunk());
+
+ await store.dispatch(checkPermissionRevocationThunk());
+
+ expect(store.getState().photos.enabled).toBe(true);
+ expect(store.getState().photos.permissionStatus).toBe('granted');
+ });
+
+ test('when checkPermissionRevocationThunk runs and backup is enabled and permission is limited, then backup stays enabled', async () => {
+ mockPermissionService.requestPermission.mockResolvedValueOnce('granted');
+ mockPermissionService.getStatus.mockResolvedValueOnce('limited');
+
+ const store = makeStore();
+ await store.dispatch(enableBackupThunk());
+
+ await store.dispatch(checkPermissionRevocationThunk());
+
+ expect(store.getState().photos.enabled).toBe(true);
+ expect(store.getState().photos.permissionStatus).toBe('limited');
+ });
+
test('when setNetworkConditionThunk runs with wifi-only after wifi-and-data, then network condition reverts and is persisted', async () => {
const store = makeStore();
await store.dispatch(setNetworkConditionThunk('wifi-and-data'));
diff --git a/src/store/slices/photos/index.ts b/src/store/slices/photos/index.ts
index a4d089d90..0c7eddf3f 100644
--- a/src/store/slices/photos/index.ts
+++ b/src/store/slices/photos/index.ts
@@ -65,6 +65,22 @@ export const disableBackupThunk = createAsyncThunk(
+ 'photos/checkPermissionRevocation',
+ async (_, { getState, dispatch }) => {
+ const { enabled } = getState().photos;
+ if (!enabled) return;
+
+ const status = await photoPermissionService.getStatus();
+ if (status === 'denied') {
+ await dispatch(disableBackupThunk());
+ dispatch(photosSlice.actions.setPermissionStatus('denied'));
+ } else {
+ dispatch(photosSlice.actions.setPermissionStatus(status));
+ }
+ },
+);
+
export const setNetworkConditionThunk = createAsyncThunk(
'photos/setNetworkCondition',
async (value, { getState, dispatch }) => {
diff --git a/src/store/slices/ui/index.ts b/src/store/slices/ui/index.ts
index 9ab80c6ec..d337b806f 100644
--- a/src/store/slices/ui/index.ts
+++ b/src/store/slices/ui/index.ts
@@ -25,6 +25,8 @@ export interface UIState {
isPlansModalOpen: boolean;
isCancelSubscriptionModalOpen: boolean;
isSharedLinkOptionsModalOpen: boolean;
+ showEmptyFileNotAllowedModal: boolean;
+ showNotEnoughDeviceSpaceModal: boolean;
}
const initialState: UIState = {
@@ -51,6 +53,8 @@ const initialState: UIState = {
isPlansModalOpen: false,
isCancelSubscriptionModalOpen: false,
isSharedLinkOptionsModalOpen: false,
+ showEmptyFileNotAllowedModal: false,
+ showNotEnoughDeviceSpaceModal: false,
};
export const uiSlice = createSlice({
@@ -124,6 +128,12 @@ export const uiSlice = createSlice({
setIsSharedLinkOptionsModalOpen: (state, action: PayloadAction) => {
state.isSharedLinkOptionsModalOpen = action.payload;
},
+ setShowEmptyFileNotAllowedModal: (state, action: PayloadAction) => {
+ state.showEmptyFileNotAllowedModal = action.payload;
+ },
+ setShowNotEnoughDeviceSpaceModal: (state, action: PayloadAction) => {
+ state.showNotEnoughDeviceSpaceModal = action.payload;
+ },
},
});
diff --git a/src/types/drive/folderUpload.ts b/src/types/drive/folderUpload.ts
index e5d716c6a..e8f8299e3 100644
--- a/src/types/drive/folderUpload.ts
+++ b/src/types/drive/folderUpload.ts
@@ -24,7 +24,7 @@ export interface FolderUploadProgress {
failedFiles: number;
}
-export type FolderUploadStatus = 'uploading' | 'cancelled' | 'completed' | 'error';
+export type FolderUploadStatus = 'scanning' | 'uploading' | 'cancelled' | 'completed' | 'error';
export interface FolderUploadState {
uploadId: string;
@@ -40,6 +40,7 @@ export interface FolderUploadResult {
totalFiles: number;
uploadedFiles: number;
failedFiles: number;
+ skippedFiles: number;
totalFolders: number;
createdFolders: number;
failedFolders: number;
diff --git a/yarn.lock b/yarn.lock
index a3ddc1980..af09f5829 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2580,9 +2580,9 @@
lodash "^4"
"@xmldom/xmldom@^0.8.8":
- version "0.8.11"
- resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608"
- integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==
+ version "0.8.12"
+ resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz#cf488a5435fa06c7374ad1449c69cea0f823624b"
+ integrity sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==
"@yarnpkg/lockfile@^1.1.0":
version "1.1.0"
@@ -2824,7 +2824,7 @@ await-lock@^2.2.2:
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.2.2.tgz#a95a9b269bfd2f69d22b17a321686f551152bcef"
integrity sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==
-axios@1.13.5, axios@^1.13.5:
+axios@1.13.5:
version "1.13.5"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.5.tgz#5e464688fa127e11a660a2c49441c009f6567a43"
integrity sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==
@@ -2833,6 +2833,15 @@ axios@1.13.5, axios@^1.13.5:
form-data "^4.0.5"
proxy-from-env "^1.1.0"
+axios@^1.15.0:
+ version "1.15.0"
+ resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.0.tgz#0fcee91ef03d386514474904b27863b2c683bf4f"
+ integrity sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==
+ dependencies:
+ follow-redirects "^1.15.11"
+ form-data "^4.0.5"
+ proxy-from-env "^2.1.0"
+
babel-jest@^29.2.1, babel-jest@^29.7.0:
version "29.7.0"
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5"
@@ -4847,9 +4856,9 @@ flat@^5.0.2:
integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
flatted@^3.2.9:
- version "3.3.3"
- resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358"
- integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
+ version "3.4.2"
+ resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726"
+ integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
flow-enums-runtime@^0.0.6:
version "0.0.6"
@@ -4857,9 +4866,9 @@ flow-enums-runtime@^0.0.6:
integrity sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==
follow-redirects@^1.15.11:
- version "1.15.11"
- resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340"
- integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==
+ version "1.16.0"
+ resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
+ integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
fontfaceobserver@^2.1.0:
version "2.3.0"
@@ -5084,9 +5093,9 @@ graphemer@^1.4.0:
integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==
handlebars@^4.7.8:
- version "4.7.8"
- resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9"
- integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==
+ version "4.7.9"
+ resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f"
+ integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==
dependencies:
minimist "^1.2.5"
neo-async "^2.6.2"
@@ -6278,10 +6287,10 @@ lodash.throttle@^4.1.1:
resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4"
integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==
-lodash@^4, lodash@^4.17.19, lodash@^4.17.23, lodash@^4.17.5:
- version "4.17.23"
- resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
- integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
+lodash@^4, lodash@^4.17.19, lodash@^4.17.5, lodash@^4.18.1:
+ version "4.18.1"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
+ integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
log-symbols@^2.2.0:
version "2.2.0"
@@ -6867,9 +6876,9 @@ node-fetch@^2.7.0:
whatwg-url "^5.0.0"
node-forge@^1.3.3:
- version "1.3.3"
- resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751"
- integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2"
+ integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==
node-int64@^0.4.0:
version "0.4.0"
@@ -7461,6 +7470,11 @@ proxy-from-env@^1.1.0:
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2"
integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==
+proxy-from-env@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
+ integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
+
pseudomap@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
@@ -8415,9 +8429,9 @@ sisteransi@^1.0.5:
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
sjcl@^1.0.3:
- version "1.0.8"
- resolved "https://registry.yarnpkg.com/sjcl/-/sjcl-1.0.8.tgz#f2ec8d7dc1f0f21b069b8914a41a8f236b0e252a"
- integrity sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ==
+ version "1.0.9"
+ resolved "https://registry.yarnpkg.com/sjcl/-/sjcl-1.0.9.tgz#650560f04c0d6d5adfa3aa8c63190a138913bb40"
+ integrity sha512-dWM71tkSHxe7zEZj0/COjtJdmErIxp7UMp8a6D4xx8dTTtJLc4lFL+HAX8s6lvASyQQ2iYMHwa7rhhQq7MT5MA==
slash@^2.0.0:
version "2.0.0"
@@ -8458,9 +8472,9 @@ socket.io-client@^4.8.3:
socket.io-parser "~4.2.4"
socket.io-parser@~4.2.4:
- version "4.2.5"
- resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.5.tgz#3f41b8d369129a93268f2abecba94b5292850099"
- integrity sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==
+ version "4.2.6"
+ resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz#19156bf179af3931abd05260cfb1491822578a6f"
+ integrity sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==
dependencies:
"@socket.io/component-emitter" "~3.1.0"
debug "~4.4.1"
@@ -8830,9 +8844,9 @@ tailwindcss@^3.0.0:
sucrase "^3.35.0"
tar@^7.5.2:
- version "7.5.10"
- resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.10.tgz#2281541123f5507db38bc6eb22619f4bbaef73ad"
- integrity sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==
+ version "7.5.11"
+ resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868"
+ integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==
dependencies:
"@isaacs/fs-minipass" "^4.0.0"
chownr "^3.0.0"
@@ -9152,9 +9166,9 @@ undici-types@~7.16.0:
integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==
undici@^6.18.2:
- version "6.23.0"
- resolved "https://registry.yarnpkg.com/undici/-/undici-6.23.0.tgz#7953087744d9095a96f115de3140ca3828aff3a4"
- integrity sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==
+ version "6.24.1"
+ resolved "https://registry.yarnpkg.com/undici/-/undici-6.24.1.tgz#9df1425cede20b836d95634347946f79578b7e71"
+ integrity sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==
unicode-canonical-property-names-ecmascript@^2.0.0:
version "2.0.1"
@@ -9561,9 +9575,9 @@ yallist@^5.0.0:
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
yaml@^2.2.2, yaml@^2.6.1:
- version "2.8.2"
- resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5"
- integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==
+ version "2.8.3"
+ resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d"
+ integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==
yargs-parser@^18.1.3:
version "18.1.3"