+## 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)
From 63f4e1cd4da09e6d5df7d04adde5f28dece4fc8e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Fri, 10 Apr 2026 03:28:38 +0000
Subject: [PATCH 11/32] Bump lodash from 4.17.23 to 4.18.1
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)
---
updated-dependencies:
- dependency-name: lodash
dependency-version: 4.18.1
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
yarn.lock | 8 ++++----
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/package.json b/package.json
index 521ed2b0c..b4c2e7e98 100644
--- a/package.json
+++ b/package.json
@@ -80,7 +80,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": "^1.1.1",
diff --git a/yarn.lock b/yarn.lock
index 0885fa4dd..d514b43e2 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6247,10 +6247,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"
From a96fbe60b7186b6b781509f9d8c1f85322036e13 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sun, 12 Apr 2026 10:26:40 +0000
Subject: [PATCH 12/32] Bump axios from 1.13.5 to 1.15.0
Bumps [axios](https://github.com/axios/axios) from 1.13.5 to 1.15.0.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.13.5...v1.15.0)
---
updated-dependencies:
- dependency-name: axios
dependency-version: 1.15.0
dependency-type: direct:production
...
Signed-off-by: dependabot[bot]
---
package.json | 2 +-
yarn.lock | 16 +++++++++++++++-
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/package.json b/package.json
index 521ed2b0c..12fd86e88 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,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",
"events": "^3.3.0",
diff --git a/yarn.lock b/yarn.lock
index 0885fa4dd..a7ce1033c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2811,7 +2811,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==
@@ -2820,6 +2820,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"
@@ -7427,6 +7436,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"
From fe7aa96b1b1a82343e6518805a4af705795afcf8 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Mon, 13 Apr 2026 15:58:01 +0200
Subject: [PATCH 13/32] Removed more info link
---
android/app/build.gradle | 4 +-
android/app/src/main/res/values/strings.xml | 2 +-
ios/Internxt.xcodeproj/project.pbxproj | 110 +++++++++---------
ios/Internxt/Info.plist | 2 +-
ios/Internxt/Supporting/Expo.plist | 2 +-
ios/Podfile | 12 ++
ios/Podfile.lock | 2 +-
package.json | 2 +-
.../modals/RunOutOfStorageModal/index.tsx | 32 +++--
src/screens/SettingsScreen/index.tsx | 18 +--
10 files changed, 98 insertions(+), 88 deletions(-)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 3829c9457..07120842a 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
applicationId 'com.internxt.cloud'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 119
- versionName "1.8.7"
+ versionCode 120
+ versionName "1.8.8"
}
flavorDimensions "react-native-capture-protection"
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 7b4d7131c..98f41ae02 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.6
+ 1.8.8containfalse
\ No newline at end of file
diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj
index 440ff7a00..45ba3f7b4 100644
--- a/ios/Internxt.xcodeproj/project.pbxproj
+++ b/ios/Internxt.xcodeproj/project.pbxproj
@@ -11,25 +11,25 @@
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
57D6A820E5DCE2CB92983793 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FD265E49E7E80E2B591908 /* ExpoModulesProvider.swift */; };
90E1B8D972DE37FCDC015520 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = C44FA7BD1F87A3F11CCE0315 /* PrivacyInfo.xcprivacy */; };
- 999A33F96CD544A2AC0D7B6F /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 000E1B46B2D6ECD672FEE0C2 /* libPods-Internxt.a */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
+ F267BBD483B511923AD3DBC0 /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = FA6F582624041029F4EA91A2 /* libPods-Internxt.a */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
- 000E1B46B2D6ECD672FEE0C2 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; };
02FD265E49E7E80E2B591908 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift"; sourceTree = ""; };
13B07F961A680F5B00A75B9A /* Internxt.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Internxt.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Internxt/Images.xcassets; sourceTree = ""; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Internxt/Info.plist; sourceTree = ""; };
- 6894BD56AC124ED79F8A62F6 /* 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 = ""; };
+ 77488452C85C3FEFB6121480 /* 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 = ""; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Internxt/SplashScreen.storyboard; sourceTree = ""; };
- B374EF6B9B21052113E381A0 /* 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 = ""; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = ""; };
C44FA7BD1F87A3F11CCE0315 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Internxt/PrivacyInfo.xcprivacy; 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 = ""; };
+ FA6F582624041029F4EA91A2 /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; };
+ FFEE1211E397D17EEFA32C9C /* 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 = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -37,7 +37,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 999A33F96CD544A2AC0D7B6F /* libPods-Internxt.a in Frameworks */,
+ F267BBD483B511923AD3DBC0 /* libPods-Internxt.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -70,7 +70,7 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
- 000E1B46B2D6ECD672FEE0C2 /* libPods-Internxt.a */,
+ FA6F582624041029F4EA91A2 /* libPods-Internxt.a */,
);
name = Frameworks;
sourceTree = "";
@@ -89,8 +89,8 @@
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
2D16E6871FA4F8E400B85C8A /* Frameworks */,
- 8D4B168B39F58668C6FAA056 /* Pods */,
232EB521714B62056DF8C85F /* ExpoModulesProviders */,
+ DDFED47DD579196F43701AE0 /* Pods */,
);
indentWidth = 2;
sourceTree = "";
@@ -105,16 +105,6 @@
name = Products;
sourceTree = "";
};
- 8D4B168B39F58668C6FAA056 /* Pods */ = {
- isa = PBXGroup;
- children = (
- 6894BD56AC124ED79F8A62F6 /* Pods-Internxt.debug.xcconfig */,
- B374EF6B9B21052113E381A0 /* Pods-Internxt.release.xcconfig */,
- );
- name = Pods;
- path = Pods;
- sourceTree = "";
- };
BB2F792B24A3F905000567C9 /* Supporting */ = {
isa = PBXGroup;
children = (
@@ -132,6 +122,16 @@
name = Internxt;
sourceTree = "";
};
+ DDFED47DD579196F43701AE0 /* Pods */ = {
+ isa = PBXGroup;
+ children = (
+ FFEE1211E397D17EEFA32C9C /* Pods-Internxt.debug.xcconfig */,
+ 77488452C85C3FEFB6121480 /* Pods-Internxt.release.xcconfig */,
+ );
+ name = Pods;
+ path = Pods;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -139,14 +139,14 @@
isa = PBXNativeTarget;
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Internxt" */;
buildPhases = (
- 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
+ E09E7F2A4DA1498920EB3368 /* [CP] Check Pods Manifest.lock */,
1EF4210DE5F5327470865F43 /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
- 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
- 1500C602ABE3DD62E15DB32D /* [CP] Embed Pods Frameworks */,
+ 5AAB6B2782F2505210FD7F26 /* [CP] Embed Pods Frameworks */,
+ 9CB122468F430E64E3197E99 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -220,29 +220,31 @@
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";
};
- 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
+ 1EF4210DE5F5327470865F43 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
- "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
- "${PODS_ROOT}/Manifest.lock",
+ "$(SRCROOT)/.xcode.env",
+ "$(SRCROOT)/.xcode.env.local",
+ "$(SRCROOT)/Internxt/Internxt.entitlements",
+ "$(SRCROOT)/Pods/Target Support Files/Pods-Internxt/expo-configure-project.sh",
);
- name = "[CP] Check Pods Manifest.lock";
+ name = "[Expo] Configure project";
outputFileListPaths = (
);
outputPaths = (
- "$(DERIVED_FILE_DIR)/Pods-Internxt-checkManifestLockResult.txt",
+ "$(SRCROOT)/Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift",
);
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 = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Internxt/expo-configure-project.sh\"\n";
};
- 1500C602ABE3DD62E15DB32D /* [CP] Embed Pods Frameworks */ = {
+ 5AAB6B2782F2505210FD7F26 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -260,31 +262,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
- 1EF4210DE5F5327470865F43 /* [Expo] Configure project */ = {
- isa = PBXShellScriptBuildPhase;
- alwaysOutOfDate = 1;
- buildActionMask = 2147483647;
- files = (
- );
- inputFileListPaths = (
- );
- inputPaths = (
- "$(SRCROOT)/.xcode.env",
- "$(SRCROOT)/.xcode.env.local",
- "$(SRCROOT)/Internxt/Internxt.entitlements",
- "$(SRCROOT)/Pods/Target Support Files/Pods-Internxt/expo-configure-project.sh",
- );
- name = "[Expo] Configure project";
- outputFileListPaths = (
- );
- outputPaths = (
- "$(SRCROOT)/Pods/Target Support Files/Pods-Internxt/ExpoModulesProvider.swift",
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Internxt/expo-configure-project.sh\"\n";
- };
- 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
+ 9CB122468F430E64E3197E99 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -336,6 +314,28 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-resources.sh\"\n";
showEnvVarsInLog = 0;
};
+ E09E7F2A4DA1498920EB3368 /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ outputPaths = (
+ "$(DERIVED_FILE_DIR)/Pods-Internxt-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;
+ };
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -353,7 +353,7 @@
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = 6894BD56AC124ED79F8A62F6 /* Pods-Internxt.debug.xcconfig */;
+ baseConfigurationReference = FFEE1211E397D17EEFA32C9C /* Pods-Internxt.debug.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
@@ -392,7 +392,7 @@
};
13B07F951A680F5B00A75B9A /* Release */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = B374EF6B9B21052113E381A0 /* Pods-Internxt.release.xcconfig */;
+ baseConfigurationReference = 77488452C85C3FEFB6121480 /* Pods-Internxt.release.xcconfig */;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
diff --git a/ios/Internxt/Info.plist b/ios/Internxt/Info.plist
index 82249a805..cc617591a 100644
--- a/ios/Internxt/Info.plist
+++ b/ios/Internxt/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType$(PRODUCT_BUNDLE_PACKAGE_TYPE)CFBundleShortVersionString
- 1.8.7
+ 1.8.8CFBundleSignature????CFBundleURLTypes
diff --git a/ios/Internxt/Supporting/Expo.plist b/ios/Internxt/Supporting/Expo.plist
index 5c2caac95..4efcac8d1 100644
--- a/ios/Internxt/Supporting/Expo.plist
+++ b/ios/Internxt/Supporting/Expo.plist
@@ -9,7 +9,7 @@
EXUpdatesLaunchWaitMs0EXUpdatesRuntimeVersion
- 1.8.7
+ 1.8.8EXUpdatesURLhttps://u.expo.dev/680f4feb-6315-4a50-93ec-36dcd0b831d2
diff --git a/ios/Podfile b/ios/Podfile
index ac87a9a93..d30e5d7d5 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -50,6 +50,18 @@ target 'Internxt' do
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
+ # 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
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index e1c4fdf11..436171025 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -3764,6 +3764,6 @@ SPEC CHECKSUMS:
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
Yoga: 728df40394d49f3f471688747cf558158b3a3bd1
-PODFILE CHECKSUM: 84fe8856a9a26835911846d67b49dc5dfde3b28c
+PODFILE CHECKSUM: 9d47f5e733240290d11f59654a678c833f321af3
COCOAPODS: 1.16.2
diff --git a/package.json b/package.json
index 521ed2b0c..79342e49d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "drive-mobile",
- "version": "v1.8.7",
+ "version": "v1.8.8",
"private": true,
"license": "GNU",
"scripts": {
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/screens/SettingsScreen/index.tsx b/src/screens/SettingsScreen/index.tsx
index 5247e4fe2..11d07059a 100644
--- a/src/screens/SettingsScreen/index.tsx
+++ b/src/screens/SettingsScreen/index.tsx
@@ -3,7 +3,6 @@ import {
CaretRight,
FileText,
FolderSimple,
- Info,
Moon,
Question,
Shield,
@@ -112,7 +111,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);
@@ -353,21 +352,6 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
),
onPress: onSupportPressed,
},
- {
- key: 'more-information',
- template: (
-
-
-
- {strings.screens.SettingsScreen.more}
-
-
-
-
-
- ),
- onPress: onMoreInfoPressed,
- },
{
key: 'share-logs',
loading: gettingLogs,
From 4a29aa471317dd7ba10ca351b6caa7fa1af3b09a Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Tue, 14 Apr 2026 10:19:07 +0200
Subject: [PATCH 14/32] Removed terms and conditions button from settings
---
android/app/build.gradle | 2 +-
src/screens/SettingsScreen/index.tsx | 22 ----------------------
2 files changed, 1 insertion(+), 23 deletions(-)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 07120842a..0a19d2b20 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,7 +91,7 @@ android {
applicationId 'com.internxt.cloud'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 120
+ versionCode 121
versionName "1.8.8"
}
diff --git a/src/screens/SettingsScreen/index.tsx b/src/screens/SettingsScreen/index.tsx
index 11d07059a..5c94653f5 100644
--- a/src/screens/SettingsScreen/index.tsx
+++ b/src/screens/SettingsScreen/index.tsx
@@ -370,28 +370,6 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
},
]}
/>
- {/* LEGAL */}
-
-
-
- {strings.screens.SettingsScreen.termsAndConditions}
-
-
-
-
-
-
- ),
- onPress: onTermsAndConditionsPressed,
- },
- ]}
- />
{/* DEBUG */}
{appService.isDevMode && (
From 081a67e17ec098f299ce667acfaab59775af0b25 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Thu, 16 Apr 2026 07:22:07 +0000
Subject: [PATCH 15/32] Bump follow-redirects from 1.15.11 to 1.16.0
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0)
---
updated-dependencies:
- dependency-name: follow-redirects
dependency-version: 1.16.0
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
yarn.lock | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/yarn.lock b/yarn.lock
index 05bcc9306..dad443166 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4840,9 +4840,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"
From d781a436adf79356290bcef2e621d4f49488c096 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Fri, 17 Apr 2026 07:24:57 +0200
Subject: [PATCH 16/32] Update sonarcloud exclusions
---
sonar-project.properties | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/sonar-project.properties b/sonar-project.properties
index c623c69a1..4c64931cc 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,2 +1,2 @@
sonar.exclusions=**/*android*.*,**/*ios*.*
-sonar.cpd.exclusions=assets/lang/strings.ts,**/*.spec.ts,**/*.spec.tsx
\ No newline at end of file
+sonar.cpd.exclusions=assets/lang/strings.ts,**/*.spec.ts,**/*.spec.tsx
From 25a5fb5ced056b15cb9a16b00a2ea10466f08d40 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Fri, 17 Apr 2026 07:29:42 +0200
Subject: [PATCH 17/32] Update sonarcloud exclusions
---
sonar-project.properties | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/sonar-project.properties b/sonar-project.properties
index 4c64931cc..4cbba1f0f 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,2 +1,2 @@
sonar.exclusions=**/*android*.*,**/*ios*.*
-sonar.cpd.exclusions=assets/lang/strings.ts,**/*.spec.ts,**/*.spec.tsx
+sonar.cpd.exclusions=**/assets/lang/strings.ts,**/*.spec.ts,**/*.spec.tsx
\ No newline at end of file
From 8181e244999185c405b2bf5ac275e1a30365fd2c Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Fri, 17 Apr 2026 11:39:26 +0200
Subject: [PATCH 18/32] bump version to 1.8.81 and remove terms and conditions
link from SignInScreen and fixed tests
---
android/app/build.gradle | 4 ++--
android/app/src/main/res/values/strings.xml | 2 +-
ios/Internxt/Info.plist | 2 +-
ios/Internxt/Supporting/Expo.plist | 2 +-
jest.config.ts | 1 +
jest.setup.ts | 5 +++++
package.json | 2 +-
src/screens/SignInScreen/index.tsx | 17 -----------------
8 files changed, 12 insertions(+), 23 deletions(-)
create mode 100644 jest.setup.ts
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 0a19d2b20..5506c1c74 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
applicationId 'com.internxt.cloud'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 121
- versionName "1.8.8"
+ versionCode 122
+ versionName "1.8.81"
}
flavorDimensions "react-native-capture-protection"
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 98f41ae02..64eddfd94 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.8
+ 1.8.81containfalse
\ No newline at end of file
diff --git a/ios/Internxt/Info.plist b/ios/Internxt/Info.plist
index cc617591a..dc9944a1f 100644
--- a/ios/Internxt/Info.plist
+++ b/ios/Internxt/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType$(PRODUCT_BUNDLE_PACKAGE_TYPE)CFBundleShortVersionString
- 1.8.8
+ 1.8.81CFBundleSignature????CFBundleURLTypes
diff --git a/ios/Internxt/Supporting/Expo.plist b/ios/Internxt/Supporting/Expo.plist
index 4efcac8d1..725826854 100644
--- a/ios/Internxt/Supporting/Expo.plist
+++ b/ios/Internxt/Supporting/Expo.plist
@@ -9,7 +9,7 @@
EXUpdatesLaunchWaitMs0EXUpdatesRuntimeVersion
- 1.8.8
+ 1.8.81EXUpdatesURLhttps://u.expo.dev/680f4feb-6315-4a50-93ec-36dcd0b831d2
diff --git a/jest.config.ts b/jest.config.ts
index 8eb79ab06..691249a86 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -26,6 +26,7 @@ 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..c3aac10bf
--- /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 (global as any).fetch;
+delete (global as any).ReadableStream;
diff --git a/package.json b/package.json
index c4e60ec46..0ed380999 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "drive-mobile",
- "version": "v1.8.8",
+ "version": "v1.8.81",
"private": true,
"license": "GNU",
"scripts": {
diff --git a/src/screens/SignInScreen/index.tsx b/src/screens/SignInScreen/index.tsx
index ac2e6ef59..4ed218193 100644
--- a/src/screens/SignInScreen/index.tsx
+++ b/src/screens/SignInScreen/index.tsx
@@ -78,18 +78,6 @@ function SignInScreen(): JSX.Element {
}
};
- 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);
- }
- };
-
const onNeedHelpPressed = async () => {
try {
const helpUrl = appService.urls.help;
@@ -180,11 +168,6 @@ function SignInScreen(): JSX.Element {
-
-
- {strings.screens.SignInScreen.termsAndConditions}
-
-
{strings.screens.SignInScreen.needHelp}
From 5d7ed597cd1ffcee02762efe05e3d6de58b7c020 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Fri, 17 Apr 2026 11:54:36 +0200
Subject: [PATCH 19/32] Uses globalThis instead globla for jest.setup.ts
---
jest.setup.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/jest.setup.ts b/jest.setup.ts
index c3aac10bf..e38a818da 100644
--- a/jest.setup.ts
+++ b/jest.setup.ts
@@ -1,5 +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 (global as any).fetch;
-delete (global as any).ReadableStream;
+delete (globalThis as any).fetch;
+delete (globalThis as any).ReadableStream;
From fc4900bac14ff43591c3c4e9f631af7a580cc336 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Fri, 17 Apr 2026 11:59:55 +0200
Subject: [PATCH 20/32] bump version to 1.8.9
---
android/app/build.gradle | 2 +-
android/app/src/main/res/values/strings.xml | 2 +-
ios/Internxt/Info.plist | 2 +-
ios/Internxt/Supporting/Expo.plist | 2 +-
package.json | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 5506c1c74..8afde8c92 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -92,7 +92,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 122
- versionName "1.8.81"
+ versionName "1.8.9"
}
flavorDimensions "react-native-capture-protection"
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 64eddfd94..46555c8d7 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.81
+ 1.8.9containfalse
\ No newline at end of file
diff --git a/ios/Internxt/Info.plist b/ios/Internxt/Info.plist
index dc9944a1f..dead96486 100644
--- a/ios/Internxt/Info.plist
+++ b/ios/Internxt/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType$(PRODUCT_BUNDLE_PACKAGE_TYPE)CFBundleShortVersionString
- 1.8.81
+ 1.8.9CFBundleSignature????CFBundleURLTypes
diff --git a/ios/Internxt/Supporting/Expo.plist b/ios/Internxt/Supporting/Expo.plist
index 725826854..bd4ee628a 100644
--- a/ios/Internxt/Supporting/Expo.plist
+++ b/ios/Internxt/Supporting/Expo.plist
@@ -9,7 +9,7 @@
EXUpdatesLaunchWaitMs0EXUpdatesRuntimeVersion
- 1.8.81
+ 1.8.9EXUpdatesURLhttps://u.expo.dev/680f4feb-6315-4a50-93ec-36dcd0b831d2
diff --git a/package.json b/package.json
index 0ed380999..def03a1cb 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "drive-mobile",
- "version": "v1.8.81",
+ "version": "v1.8.9",
"private": true,
"license": "GNU",
"scripts": {
From f6b6f48b7c7cbf96e0aa6de609a59a045613db29 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Tue, 21 Apr 2026 11:18:03 +0200
Subject: [PATCH 21/32] Fixed open link bug in Android devices (samsung knox
issue)
---
src/helpers/utils.ts | 11 ++---
src/screens/SignInScreen/index.tsx | 71 +++++++-----------------------
2 files changed, 20 insertions(+), 62 deletions(-)
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/screens/SignInScreen/index.tsx b/src/screens/SignInScreen/index.tsx
index 4ed218193..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,63 +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);
- }
+ });
+ 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 = () => {
@@ -97,7 +60,7 @@ function SignInScreen(): JSX.Element {
return (
-
+ {error}
);
@@ -106,7 +69,7 @@ function SignInScreen(): JSX.Element {
return (
{isDark ? (
From 202dca431431eb7233d9b143235caeead5c3419a Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Tue, 21 Apr 2026 17:16:19 +0200
Subject: [PATCH 22/32] Added support for uploading empty files, check plan
restrictions and fixed folder upload cancelation
---
assets/lang/strings.ts | 16 +++
.../modals/AddModal/hooks/useFolderUpload.ts | 41 ++++--
src/components/modals/AddModal/index.tsx | 35 +++++-
.../modals/EmptyFileNotAllowedModal/index.tsx | 44 +++++++
src/navigation/TabExplorerNavigator.tsx | 2 +
src/network/NetworkFacade.ts | 91 +++++++++-----
src/network/errors.ts | 8 ++
src/network/upload.ts | 16 ++-
.../drive/file/utils/emptyFileErrors.ts | 13 ++
.../drive/file/utils/uploadFileUtils.ts | 15 ++-
.../folderOrchestration.service.spec.ts | 119 +++++++++++-------
.../folder/folderOrchestration.service.ts | 14 ++-
.../ShareExtensionView.android.tsx | 2 +
src/shareExtension/ShareExtensionView.ios.tsx | 2 +
.../components/UploadSuccessCard.tsx | 7 +-
src/shareExtension/errors.ts | 4 +
src/shareExtension/hooks/useShareUpload.ts | 29 ++++-
src/shareExtension/screens/DriveScreen.tsx | 3 +
.../services/shareUploadService.ts | 52 +++++---
src/shareExtension/types.ts | 3 +-
src/shareExtension/utils.ts | 2 +
src/store/slices/ui/index.ts | 5 +
src/types/drive/folderUpload.ts | 1 +
23 files changed, 409 insertions(+), 115 deletions(-)
create mode 100644 src/components/modals/EmptyFileNotAllowedModal/index.tsx
create mode 100644 src/network/errors.ts
create mode 100644 src/services/drive/file/utils/emptyFileErrors.ts
diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts
index 311962177..6e4026860 100644
--- a/assets/lang/strings.ts
+++ b/assets/lang/strings.ts
@@ -353,6 +353,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?',
@@ -672,6 +673,11 @@ 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.',
},
+ 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!',
@@ -741,7 +747,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',
@@ -1197,6 +1205,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?',
@@ -1518,6 +1527,11 @@ const translations = {
advice:
'Mejora tu plan o borra los archivos que no vayas a usar para subir o sincronizar tus archivos 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!',
@@ -1590,7 +1604,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/src/components/modals/AddModal/hooks/useFolderUpload.ts b/src/components/modals/AddModal/hooks/useFolderUpload.ts
index 448ac968a..1e5d935b4 100644
--- a/src/components/modals/AddModal/hooks/useFolderUpload.ts
+++ b/src/components/modals/AddModal/hooks/useFolderUpload.ts
@@ -5,6 +5,7 @@ import uuid from 'react-native-uuid';
import { useDrive } from '@internxt-mobile/hooks/drive';
import { logger } from '@internxt-mobile/services/common';
+import { EmptyFileNotAllowedError } from '@internxt-mobile/services/drive/file/utils/emptyFileErrors';
import errorService from '@internxt-mobile/services/ErrorService';
import { DriveFileData } from '@internxt-mobile/types/drive/file';
import strings from '../../../../../assets/lang/strings';
@@ -33,12 +34,12 @@ import { NameCollisionAction } from '../../NameCollisionModal';
const noopProgress: ProgressCallback = () => {};
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 +48,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 +72,7 @@ type UploadFileEntryFn = (
progressCallback: ProgressCallback,
modificationTime?: string,
creationTime?: string,
+ signal?: AbortSignal,
) => Promise;
export interface FolderUploadCollisionModalState {
@@ -118,10 +121,29 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
const handleUploadFolder = async () => {
dispatch(uiActions.setShowUploadFileModal(false));
- const uploadFolderFileIOS = async (fileNode: FolderTreeNode, parentUuid: string): Promise => {
+ let hasShownEmptyFileNotice = false;
+
+ const handleEmptyFileSkipped = () => {
+ if (!hasShownEmptyFileNotice) {
+ hasShownEmptyFileNotice = true;
+ notificationsService.show({
+ type: NotificationType.Warning,
+ text1: strings.messages.emptyFileSkippedDuringFolderUpload,
+ });
+ }
+ };
+
+ const uploadFolderFileIOS = async (fileNode: FolderTreeNode, parentUuid: string, signal: AbortSignal): Promise => {
const filePath = fileNode.uri.replace('file://', '');
const { extension, plainName } = getFileExtensionAndPlainName(fileNode.name);
- await uploadAndCreateFileEntry(filePath, plainName, extension, parentUuid, noopProgress);
+ try {
+ await uploadAndCreateFileEntry(filePath, plainName, extension, parentUuid, noopProgress, undefined, undefined, signal);
+ } catch (err) {
+ if (err instanceof EmptyFileNotAllowedError) {
+ handleEmptyFileSkipped();
+ }
+ throw err;
+ }
};
const uploadFolderFileAndroid = async (
@@ -137,7 +159,12 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
try {
await StorageAccessFramework.copyAsync({ from: fileNode.uri, to: tempUri });
if (signal.aborted) return;
- await uploadAndCreateFileEntry(tempPath, plainName, extension, parentUuid, noopProgress);
+ await uploadAndCreateFileEntry(tempPath, plainName, extension, parentUuid, noopProgress, undefined, undefined, signal);
+ } catch (err) {
+ if (err instanceof EmptyFileNotAllowedError) {
+ handleEmptyFileSkipped();
+ }
+ throw err;
} finally {
await fileSystemService.unlinkIfExists(tempPath).catch((e) => {
logger.warn('[useFolderUpload] Failed to unlink temp file: ' + (e as Error).message);
@@ -231,7 +258,7 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
if (Platform.OS === 'android' && fileNode.uri.startsWith('content://')) {
await uploadFolderFileAndroid(fileNode, parentUuid, signal, uploadId);
} else {
- await uploadFolderFileIOS(fileNode, parentUuid);
+ await uploadFolderFileIOS(fileNode, parentUuid, signal);
}
},
});
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/navigation/TabExplorerNavigator.tsx b/src/navigation/TabExplorerNavigator.tsx
index 2c61d4ccb..c40f20226 100644
--- a/src/navigation/TabExplorerNavigator.tsx
+++ b/src/navigation/TabExplorerNavigator.tsx
@@ -15,6 +15,7 @@ import DriveItemInfoModal from '../components/modals/DriveItemInfoModal';
import DriveRenameModal from '../components/modals/DriveRenameModal';
import MoveItemsModal from '../components/modals/MoveItemsModal';
import RunOutOfStorageModal from '../components/modals/RunOutOfStorageModal';
+import EmptyFileNotAllowedModal from '../components/modals/EmptyFileNotAllowedModal';
import { SharedLinkInfoModal } from '../components/modals/SharedLinkInfoModal';
import SignOutModal from '../components/modals/SignOutModal';
import useGetColor from '../hooks/useColor';
@@ -86,6 +87,7 @@ export default function TabExplorerNavigator(props: RootStackScreenProps<'TabExp
+
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/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/shareExtension/ShareExtensionView.android.tsx b/src/shareExtension/ShareExtensionView.android.tsx
index d5d6b1da6..d3c1a7888 100644
--- a/src/shareExtension/ShareExtensionView.android.tsx
+++ b/src/shareExtension/ShareExtensionView.android.tsx
@@ -22,6 +22,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
uploadError,
progress: uploadProgress,
thumbnailUri,
+ uploadedCount,
collisionState,
uploadFiles,
handleCollisionAction,
@@ -77,6 +78,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
uploadError={uploadError}
uploadProgress={uploadProgress}
thumbnailUri={thumbnailUri}
+ uploadedCount={uploadedCount}
collisionState={collisionState}
onClose={handleClose}
onSave={handleSave}
diff --git a/src/shareExtension/ShareExtensionView.ios.tsx b/src/shareExtension/ShareExtensionView.ios.tsx
index feefc6011..806316848 100644
--- a/src/shareExtension/ShareExtensionView.ios.tsx
+++ b/src/shareExtension/ShareExtensionView.ios.tsx
@@ -51,6 +51,7 @@ const ShareExtensionView = ({
uploadError,
progress: uploadProgress,
thumbnailUri,
+ uploadedCount,
collisionState,
uploadFiles,
handleCollisionAction,
@@ -91,6 +92,7 @@ const ShareExtensionView = ({
uploadError={uploadError}
uploadProgress={uploadProgress}
thumbnailUri={thumbnailUri}
+ uploadedCount={uploadedCount}
collisionState={collisionState}
onClose={close}
onSave={handleSave}
diff --git a/src/shareExtension/components/UploadSuccessCard.tsx b/src/shareExtension/components/UploadSuccessCard.tsx
index 129b75311..7b5e30b7a 100644
--- a/src/shareExtension/components/UploadSuccessCard.tsx
+++ b/src/shareExtension/components/UploadSuccessCard.tsx
@@ -13,6 +13,7 @@ interface UploadSuccessCardProps {
sharedFiles: SharedFile[];
uploadedFileName: string;
thumbnailUri?: string | null;
+ uploadedCount?: number;
onClose: () => void;
onViewInFolder: () => void;
}
@@ -21,6 +22,7 @@ export const UploadSuccessCard = ({
sharedFiles,
uploadedFileName,
thumbnailUri,
+ uploadedCount,
onClose,
onViewInFolder,
}: UploadSuccessCardProps) => {
@@ -37,7 +39,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 +54,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
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..0614de3a7 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,
@@ -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);
}
} 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..4accc6b0d 100644
--- a/src/shareExtension/screens/DriveScreen.tsx
+++ b/src/shareExtension/screens/DriveScreen.tsx
@@ -33,6 +33,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 +50,7 @@ export const DriveScreen = ({
uploadError,
uploadProgress,
thumbnailUri,
+ uploadedCount,
collisionState,
onClose,
onSave,
@@ -219,6 +221,7 @@ export const DriveScreen = ({
sharedFiles={sharedFiles}
uploadedFileName={finalName}
thumbnailUri={thumbnailUri}
+ uploadedCount={uploadedCount}
onClose={onClose}
onViewInFolder={handleViewInFolder}
/>
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/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.ts b/src/shareExtension/utils.ts
index f67e20dcb..2e281bc24 100644
--- a/src/shareExtension/utils.ts
+++ b/src/shareExtension/utils.ts
@@ -92,6 +92,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/ui/index.ts b/src/store/slices/ui/index.ts
index 9ab80c6ec..96ce7afbe 100644
--- a/src/store/slices/ui/index.ts
+++ b/src/store/slices/ui/index.ts
@@ -25,6 +25,7 @@ export interface UIState {
isPlansModalOpen: boolean;
isCancelSubscriptionModalOpen: boolean;
isSharedLinkOptionsModalOpen: boolean;
+ showEmptyFileNotAllowedModal: boolean;
}
const initialState: UIState = {
@@ -51,6 +52,7 @@ const initialState: UIState = {
isPlansModalOpen: false,
isCancelSubscriptionModalOpen: false,
isSharedLinkOptionsModalOpen: false,
+ showEmptyFileNotAllowedModal: false,
};
export const uiSlice = createSlice({
@@ -124,6 +126,9 @@ export const uiSlice = createSlice({
setIsSharedLinkOptionsModalOpen: (state, action: PayloadAction) => {
state.isSharedLinkOptionsModalOpen = action.payload;
},
+ setShowEmptyFileNotAllowedModal: (state, action: PayloadAction) => {
+ state.showEmptyFileNotAllowedModal = action.payload;
+ },
},
});
diff --git a/src/types/drive/folderUpload.ts b/src/types/drive/folderUpload.ts
index e5d716c6a..94f94710c 100644
--- a/src/types/drive/folderUpload.ts
+++ b/src/types/drive/folderUpload.ts
@@ -40,6 +40,7 @@ export interface FolderUploadResult {
totalFiles: number;
uploadedFiles: number;
failedFiles: number;
+ skippedFiles: number;
totalFolders: number;
createdFolders: number;
failedFolders: number;
From 9ccf1f54a5be3b30b043aac3174b32cb2386e2d5 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Wed, 22 Apr 2026 10:18:27 +0200
Subject: [PATCH 23/32] added folder scanning state, modal for device space
alert and fix android SAF folder scanning and item naming
---
assets/lang/strings.ts | 14 ++
ios/Internxt.xcodeproj/project.pbxproj | 210 ++++++++---------
.../DriveGridModeItem/DriveGridModeItem.tsx | 22 +-
.../DriveListModeItem/DriveListModeItem.tsx | 10 +-
.../modals/AddModal/hooks/useFolderUpload.ts | 82 +++++--
.../NotEnoughDeviceSpaceModal/index.tsx | 77 +++++++
src/navigation/TabExplorerNavigator.tsx | 2 +
.../DriveFolderScreen.helpers.ts | 19 +-
.../DriveFolderScreen/DriveFolderScreen.tsx | 6 +-
.../folder/folderTraversal.service.spec.ts | 214 ++++++++++++++++--
.../drive/folder/folderTraversal.service.ts | 76 +++----
.../drive/folder/folderUpload.service.ts | 5 +-
.../drive/folder/utils/safUri.spec.ts | 101 +++++++++
src/services/drive/folder/utils/safUri.ts | 23 ++
src/store/slices/ui/index.ts | 5 +
src/types/drive/folderUpload.ts | 2 +-
16 files changed, 669 insertions(+), 199 deletions(-)
create mode 100644 src/components/modals/NotEnoughDeviceSpaceModal/index.tsx
create mode 100644 src/services/drive/folder/utils/safUri.spec.ts
create mode 100644 src/services/drive/folder/utils/safUri.ts
diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts
index 6e4026860..1dd241ca6 100644
--- a/assets/lang/strings.ts
+++ b/assets/lang/strings.ts
@@ -206,6 +206,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}%',
@@ -673,6 +675,11 @@ 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:
@@ -1061,6 +1068,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}%',
@@ -1527,6 +1536,11 @@ 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:
diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj
index 791b4bb04..eea9f361c 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 */; };
+ 353DD4AD43EF923515032E17 /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = AE464089D2AF26E8CAEEC6CB /* libPods-Internxt.a */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
+ 61F2D7A88201F06723D33BDC /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = BBED6BCCDD72C0D72F796F82 /* 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 = ""; };
+ 63B48B926126B5434D8744F8 /* 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; };
A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-InternxtShareExtension/ExpoModulesProvider.swift"; 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 = ""; };
+ AE464089D2AF26E8CAEEC6CB /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; };
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; };
+ BBED6BCCDD72C0D72F796F82 /* 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 = ""; };
+ C00B75BF3D056BA881E694AC /* 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 = ""; };
+ C0F415F987B8A59D7CD72A59 /* 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 = ""; };
DAE15E8DB4DF4E048E04B0E0 /* Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ DD6CED1DD255C7CBCA0B6717 /* 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 = ""; };
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 */,
+ 353DD4AD43EF923515032E17 /* libPods-Internxt.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -95,7 +95,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
- 24C09574C61FF55529DAFF8A /* libPods-InternxtShareExtension.a in Frameworks */,
+ 61F2D7A88201F06723D33BDC /* 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 */,
+ AE464089D2AF26E8CAEEC6CB /* libPods-Internxt.a */,
+ BBED6BCCDD72C0D72F796F82 /* 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 */,
+ C0F415F987B8A59D7CD72A59 /* Pods-Internxt.debug.xcconfig */,
+ C00B75BF3D056BA881E694AC /* Pods-Internxt.release.xcconfig */,
+ 63B48B926126B5434D8744F8 /* Pods-InternxtShareExtension.debug.xcconfig */,
+ DD6CED1DD255C7CBCA0B6717 /* 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 */,
+ 9749A9A2F46DB1E7F4196AEC /* [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 */,
+ A8138DEFA393F6FFB394E7B5 /* [CP] Embed Pods Frameworks */,
+ 4B3A95EC80E71334D4D0CAD2 /* [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 */,
+ FF344C830A01D2CD57C3053D /* [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 */,
+ E0007E640EC3051DD157AAB4 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -343,71 +343,7 @@
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 */ = {
- 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 = "[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 = "\"${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh\"\n";
- showEnvVarsInLog = 0;
- };
- 6A9D51C204F887718EC358B4 /* [CP] Embed Pods Frameworks */ = {
- isa = PBXShellScriptBuildPhase;
- buildActionMask = 2147483647;
- files = (
- );
- 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",
- );
- 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",
- );
- runOnlyForDeploymentPostprocessing = 0;
- shellPath = /bin/sh;
- shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh\"\n";
- showEnvVarsInLog = 0;
- };
- 70E986EE3D72E31C86FF20C3 /* [CP] Copy Pods Resources */ = {
+ 4B3A95EC80E71334D4D0CAD2 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -467,7 +403,7 @@
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";
};
- AA3DED567AE440A188E26BB3 /* [CP] Check Pods Manifest.lock */ = {
+ 9749A9A2F46DB1E7F4196AEC /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -489,41 +425,41 @@
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;
};
- AA9E71431FBB401A8272B8C4 /* Bundle React Native code and images */ = {
+ A8138DEFA393F6FFB394E7B5 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
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",
);
- name = "Bundle React Native code and images";
+ 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",
);
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 = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
};
- 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;
@@ -573,6 +509,70 @@
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Internxt/expo-configure-project.sh\"\n";
};
+ E0007E640EC3051DD157AAB4 /* [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 = "[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 = "\"${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
+ FF344C830A01D2CD57C3053D /* [CP] Check Pods Manifest.lock */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ );
+ inputPaths = (
+ "${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+ "${PODS_ROOT}/Manifest.lock",
+ );
+ name = "[CP] Check Pods Manifest.lock";
+ outputFileListPaths = (
+ );
+ 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;
+ };
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -609,7 +609,7 @@
/* Begin XCBuildConfiguration section */
00BF7E5F54544AE9B1CDE9D5 /* Debug */ = {
isa = XCBuildConfiguration;
- baseConfigurationReference = B85DFBD084E638185FB887AE /* Pods-InternxtShareExtension.debug.xcconfig */;
+ baseConfigurationReference = 63B48B926126B5434D8744F8 /* 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 = C0F415F987B8A59D7CD72A59 /* 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 = C00B75BF3D056BA881E694AC /* 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 = DD6CED1DD255C7CBCA0B6717 /* Pods-InternxtShareExtension.release.xcconfig */;
buildSettings = {
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = InternxtShareExtension/InternxtShareExtension.entitlements;
diff --git a/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx b/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
index 8d24b9a1c..1046174c8 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 = () => {
@@ -121,7 +123,25 @@ function DriveGridModeItemComp(props: DriveItemProps): JSX.Element {
{isUploading &&
- (isFolderUploading ? (
+ (isFolderScanning ? (
+
+
+
+ {strings.screens.drive.scanningFolderShort}
+
+
+
+
+ ) : isFolderUploading ? (
{
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 (
state.drive.folderUploads);
+ const { limit } = useAppSelector((state) => state.storage);
+ const usage = useAppSelector(storageSelectors.usage);
const collisionResolverRef = useRef<((action: NameCollisionAction | null) => void) | null>(null);
const [collisionState, setCollisionState] = useState<{
@@ -133,11 +136,24 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
}
};
- const uploadFolderFileIOS = async (fileNode: FolderTreeNode, parentUuid: string, signal: AbortSignal): Promise => {
+ const uploadFolderFileIOS = async (
+ fileNode: FolderTreeNode,
+ parentUuid: string,
+ signal: AbortSignal,
+ ): Promise => {
const filePath = fileNode.uri.replace('file://', '');
const { extension, plainName } = getFileExtensionAndPlainName(fileNode.name);
try {
- await uploadAndCreateFileEntry(filePath, plainName, extension, parentUuid, noopProgress, undefined, undefined, signal);
+ await uploadAndCreateFileEntry(
+ filePath,
+ plainName,
+ extension,
+ parentUuid,
+ noopProgress,
+ undefined,
+ undefined,
+ signal,
+ );
} catch (err) {
if (err instanceof EmptyFileNotAllowedError) {
handleEmptyFileSkipped();
@@ -159,7 +175,16 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
try {
await StorageAccessFramework.copyAsync({ from: fileNode.uri, to: tempUri });
if (signal.aborted) return;
- await uploadAndCreateFileEntry(tempPath, plainName, extension, parentUuid, noopProgress, undefined, undefined, signal);
+ await uploadAndCreateFileEntry(
+ tempPath,
+ plainName,
+ extension,
+ parentUuid,
+ noopProgress,
+ undefined,
+ undefined,
+ signal,
+ );
} catch (err) {
if (err instanceof EmptyFileNotAllowedError) {
handleEmptyFileSkipped();
@@ -173,6 +198,7 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
};
const uploadId = uuid.v4().toString();
+ const abortController = new AbortController();
try {
// 1. Pick folder
@@ -180,14 +206,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;
}
@@ -209,25 +259,17 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
}
}
- // 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, {
@@ -280,6 +322,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/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/navigation/TabExplorerNavigator.tsx b/src/navigation/TabExplorerNavigator.tsx
index c40f20226..e12580e9b 100644
--- a/src/navigation/TabExplorerNavigator.tsx
+++ b/src/navigation/TabExplorerNavigator.tsx
@@ -16,6 +16,7 @@ import DriveRenameModal from '../components/modals/DriveRenameModal';
import MoveItemsModal from '../components/modals/MoveItemsModal';
import RunOutOfStorageModal from '../components/modals/RunOutOfStorageModal';
import EmptyFileNotAllowedModal from '../components/modals/EmptyFileNotAllowedModal';
+import NotEnoughDeviceSpaceModal from '../components/modals/NotEnoughDeviceSpaceModal';
import { SharedLinkInfoModal } from '../components/modals/SharedLinkInfoModal';
import SignOutModal from '../components/modals/SignOutModal';
import useGetColor from '../hooks/useColor';
@@ -88,6 +89,7 @@ export default function TabExplorerNavigator(props: RootStackScreenProps<'TabExp
+
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/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..960553da0 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, SAF_VOLUME_PREFIX_RE } 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 && !SAF_VOLUME_PREFIX_RE.test(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..c904e5835
--- /dev/null
+++ b/src/services/drive/folder/utils/safUri.spec.ts
@@ -0,0 +1,101 @@
+import { getNameFromSafUri, getSafTreeName, SAF_VOLUME_PREFIX_RE } from './safUri';
+
+const tree = 'content://com.android.externalstorage.documents/tree/primary%3ADownload';
+const childUri = (encoded: string) => `${tree}/document/${encoded}`;
+
+describe('SAF_VOLUME_PREFIX_RE', () => {
+ 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 matches', (input) => {
+ expect(SAF_VOLUME_PREFIX_RE.test(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 does not match', (input) => {
+ expect(SAF_VOLUME_PREFIX_RE.test(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..91e8902fe
--- /dev/null
+++ b/src/services/drive/folder/utils/safUri.ts
@@ -0,0 +1,23 @@
+// Matches SAF storage volume prefixes only
+export const SAF_VOLUME_PREFIX_RE =
+ /^(primary|secondary|home|downloads|raw|[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):/;
+
+/**
+ * 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 = decoded.replace(SAF_VOLUME_PREFIX_RE, '');
+ 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 rawName.replace(SAF_VOLUME_PREFIX_RE, '') || fallback;
+};
diff --git a/src/store/slices/ui/index.ts b/src/store/slices/ui/index.ts
index 96ce7afbe..d337b806f 100644
--- a/src/store/slices/ui/index.ts
+++ b/src/store/slices/ui/index.ts
@@ -26,6 +26,7 @@ export interface UIState {
isCancelSubscriptionModalOpen: boolean;
isSharedLinkOptionsModalOpen: boolean;
showEmptyFileNotAllowedModal: boolean;
+ showNotEnoughDeviceSpaceModal: boolean;
}
const initialState: UIState = {
@@ -53,6 +54,7 @@ const initialState: UIState = {
isCancelSubscriptionModalOpen: false,
isSharedLinkOptionsModalOpen: false,
showEmptyFileNotAllowedModal: false,
+ showNotEnoughDeviceSpaceModal: false,
};
export const uiSlice = createSlice({
@@ -129,6 +131,9 @@ export const uiSlice = createSlice({
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 94f94710c..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;
From 87089e20266c93d58f03ee22c82e58c7b534a235 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Wed, 22 Apr 2026 11:29:27 +0200
Subject: [PATCH 24/32] Reduced complexity of some functions
---
.../DriveGridModeItem/DriveGridModeItem.tsx | 115 ++++++++++--------
.../modals/AddModal/hooks/useFolderUpload.ts | 107 +++++++---------
src/services/drive/folder/utils/safUri.ts | 4 +-
3 files changed, 109 insertions(+), 117 deletions(-)
diff --git a/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx b/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
index 1046174c8..55bd6a766 100644
--- a/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
+++ b/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
@@ -98,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 &&
- (isFolderScanning ? (
-
-
-
- {strings.screens.drive.scanningFolderShort}
-
-
-
-
- ) : isFolderUploading ? (
-
-
-
-
- {props.folderUploadProgress?.totalFiles
- ? `${props.folderUploadProgress.uploadedFiles}/${props.folderUploadProgress.totalFiles}`
- : '...'}
-
-
-
-
- ) : (
-
-
-
-
- {((props.progress || 0) * 100).toFixed(0) + '%'}
-
-
-
- ))}
+ {renderUploadOverlay()}
diff --git a/src/components/modals/AddModal/hooks/useFolderUpload.ts b/src/components/modals/AddModal/hooks/useFolderUpload.ts
index e5ede71e2..726b02d73 100644
--- a/src/components/modals/AddModal/hooks/useFolderUpload.ts
+++ b/src/components/modals/AddModal/hooks/useFolderUpload.ts
@@ -34,6 +34,48 @@ import { NameCollisionAction } from '../../NameCollisionModal';
// eslint-disable-next-line @typescript-eslint/no-empty-function
const noopProgress: ProgressCallback = () => {};
+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; skippedFiles: number },
folderName: string,
@@ -136,67 +178,6 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
}
};
- const uploadFolderFileIOS = async (
- fileNode: FolderTreeNode,
- parentUuid: string,
- signal: AbortSignal,
- ): 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) {
- handleEmptyFileSkipped();
- }
- throw err;
- }
- };
-
- 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,
- undefined,
- undefined,
- signal,
- );
- } catch (err) {
- if (err instanceof EmptyFileNotAllowedError) {
- handleEmptyFileSkipped();
- }
- throw err;
- } finally {
- await fileSystemService.unlinkIfExists(tempPath).catch((e) => {
- logger.warn('[useFolderUpload] Failed to unlink temp file: ' + (e as Error).message);
- });
- }
- };
-
const uploadId = uuid.v4().toString();
const abortController = new AbortController();
@@ -298,9 +279,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, signal);
+ await uploadFolderFileIOS(fileNode, parentUuid, signal, uploadAndCreateFileEntry, handleEmptyFileSkipped);
}
},
});
diff --git a/src/services/drive/folder/utils/safUri.ts b/src/services/drive/folder/utils/safUri.ts
index 91e8902fe..107433212 100644
--- a/src/services/drive/folder/utils/safUri.ts
+++ b/src/services/drive/folder/utils/safUri.ts
@@ -1,6 +1,6 @@
-// Matches SAF storage volume prefixes only
+// Matches SAF storage volume prefixes
export const SAF_VOLUME_PREFIX_RE =
- /^(primary|secondary|home|downloads|raw|[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):/;
+ /^(primary|secondary|home|downloads|raw|[0-9a-f]{4}-[0-9a-f]{4}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):/i;
/**
* Extracts the display name from a SAF child document URI.
From 3e308a691b66678fc1280e054dff062be4ce3a1e Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Wed, 22 Apr 2026 11:50:23 +0200
Subject: [PATCH 25/32] Reduced complexity of SAF regex and useFolderUpload
hook
---
.../modals/AddModal/hooks/useFolderUpload.ts | 54 ++++++++++---------
.../drive/folder/folderTraversal.service.ts | 4 +-
.../drive/folder/utils/safUri.spec.ts | 12 ++---
src/services/drive/folder/utils/safUri.ts | 16 ++++--
4 files changed, 49 insertions(+), 37 deletions(-)
diff --git a/src/components/modals/AddModal/hooks/useFolderUpload.ts b/src/components/modals/AddModal/hooks/useFolderUpload.ts
index 726b02d73..dcfe7ab38 100644
--- a/src/components/modals/AddModal/hooks/useFolderUpload.ts
+++ b/src/components/modals/AddModal/hooks/useFolderUpload.ts
@@ -132,6 +132,18 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF
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<{
isOpen: boolean;
@@ -163,20 +175,22 @@ 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));
-
- let hasShownEmptyFileNotice = false;
-
- const handleEmptyFileSkipped = () => {
- if (!hasShownEmptyFileNotice) {
- hasShownEmptyFileNotice = true;
- notificationsService.show({
- type: NotificationType.Warning,
- text1: strings.messages.emptyFileSkippedDuringFolderUpload,
- });
- }
- };
+ hasShownEmptyFileNoticeRef.current = false;
const uploadId = uuid.v4().toString();
const abortController = new AbortController();
@@ -227,18 +241,10 @@ 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;
// 5. Create the root folder (merge if already exists)
const rootFolderUuid = await createFolderWithMerge(focusedFolder.uuid, picked.name);
diff --git a/src/services/drive/folder/folderTraversal.service.ts b/src/services/drive/folder/folderTraversal.service.ts
index 960553da0..94b011568 100644
--- a/src/services/drive/folder/folderTraversal.service.ts
+++ b/src/services/drive/folder/folderTraversal.service.ts
@@ -3,7 +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, SAF_VOLUME_PREFIX_RE } from './utils/safUri';
+import { getNameFromSafUri, hasSafVolumePrefix } from './utils/safUri';
const MAX_FILES = 3000;
@@ -78,7 +78,7 @@ const traverseSafChildren = async (
const info = await getInfoAsync(childUri).catch(() => ({ exists: false as const }));
const infoName = (info as { name?: string }).name;
- const useInfoName = infoName != null && !SAF_VOLUME_PREFIX_RE.test(infoName);
+ const useInfoName = infoName != null && !hasSafVolumePrefix(infoName);
const name = useInfoName ? infoName : getNameFromSafUri(childUri);
const relativePath = currentRelativePath ? `${currentRelativePath}/${name}` : name;
diff --git a/src/services/drive/folder/utils/safUri.spec.ts b/src/services/drive/folder/utils/safUri.spec.ts
index c904e5835..79038a1f2 100644
--- a/src/services/drive/folder/utils/safUri.spec.ts
+++ b/src/services/drive/folder/utils/safUri.spec.ts
@@ -1,9 +1,9 @@
-import { getNameFromSafUri, getSafTreeName, SAF_VOLUME_PREFIX_RE } from './safUri';
+import { getNameFromSafUri, getSafTreeName, hasSafVolumePrefix } from './safUri';
const tree = 'content://com.android.externalstorage.documents/tree/primary%3ADownload';
const childUri = (encoded: string) => `${tree}/document/${encoded}`;
-describe('SAF_VOLUME_PREFIX_RE', () => {
+describe('hasSafVolumePrefix', () => {
test.each([
['primary:DCIM', 'primary'],
['secondary:folder', 'secondary'],
@@ -12,8 +12,8 @@ describe('SAF_VOLUME_PREFIX_RE', () => {
['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 matches', (input) => {
- expect(SAF_VOLUME_PREFIX_RE.test(input)).toBe(true);
+ ])('when the input is "%s" (%s), then it returns true', (input) => {
+ expect(hasSafVolumePrefix(input)).toBe(true);
});
test.each([
@@ -21,8 +21,8 @@ describe('SAF_VOLUME_PREFIX_RE', () => {
['my-folder', 'no colon'],
['IMG_20260417.jpeg', 'plain filename'],
['', 'empty string'],
- ])('when the input is "%s" (%s), then it does not match', (input) => {
- expect(SAF_VOLUME_PREFIX_RE.test(input)).toBe(false);
+ ])('when the input is "%s" (%s), then it returns false', (input) => {
+ expect(hasSafVolumePrefix(input)).toBe(false);
});
});
diff --git a/src/services/drive/folder/utils/safUri.ts b/src/services/drive/folder/utils/safUri.ts
index 107433212..b444146b3 100644
--- a/src/services/drive/folder/utils/safUri.ts
+++ b/src/services/drive/folder/utils/safUri.ts
@@ -1,6 +1,12 @@
-// Matches SAF storage volume prefixes
-export const SAF_VOLUME_PREFIX_RE =
- /^(primary|secondary|home|downloads|raw|[0-9a-f]{4}-[0-9a-f]{4}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}):/i;
+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.
@@ -8,7 +14,7 @@ export const SAF_VOLUME_PREFIX_RE =
export const getNameFromSafUri = (childUri: string): string => {
const documentPart = childUri.split('/document/').pop() ?? '';
const decoded = decodeURIComponent(documentPart);
- const withoutVolume = decoded.replace(SAF_VOLUME_PREFIX_RE, '');
+ const withoutVolume = stripSafVolumePrefix(decoded);
return withoutVolume.split('/').pop() || withoutVolume;
};
@@ -19,5 +25,5 @@ export const getNameFromSafUri = (childUri: string): string => {
export const getSafTreeName = (treeUri: string, fallback: string): string => {
const decoded = decodeURIComponent(treeUri);
const rawName = decoded.replace(/\/$/, '').split('/').pop() ?? '';
- return rawName.replace(SAF_VOLUME_PREFIX_RE, '') || fallback;
+ return stripSafVolumePrefix(rawName) || fallback;
};
From fac38b3f9ad84577b0ed5fe91f2f0eda483a87dc Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Wed, 22 Apr 2026 18:10:00 +0200
Subject: [PATCH 26/32] Refactor UI components to use dynamic color theming,
fixed uri for display thumbnail of success upload file, added new
notification label component
---
assets/lang/strings.ts | 4 +
ios/InternxtShareExtension/Info.plist | 8 +-
jest.config.ts | 1 +
.../ShareExtensionView.android.tsx | 9 +-
src/shareExtension/ShareExtensionView.ios.tsx | 5 +-
.../components/BottomFilePanel.tsx | 50 ++++++----
src/shareExtension/components/DriveHeader.tsx | 28 +++---
.../components/DriveScreen/DriveList.tsx | 5 +-
.../components/DriveScreen/RootHeader.tsx | 9 +-
.../components/DriveScreen/SortRow.tsx | 5 +-
.../DriveScreen/SubfolderHeader.tsx | 15 +--
.../components/FileListItem.tsx | 14 +--
.../components/NewFolderModal.tsx | 38 ++++---
.../components/NotificationLabel.tsx | 99 +++++++++++++++++++
.../components/ShareNameCollisionModal.tsx | 19 ++--
.../components/UploadFeedback.tsx | 47 ++++-----
.../components/UploadSuccessCard.tsx | 32 +++---
src/shareExtension/hooks/useShareUpload.ts | 4 +-
src/shareExtension/screens/DriveScreen.tsx | 39 +++++---
.../screens/NotSignedInScreen.tsx | 36 +++----
src/shareExtension/theme.ts | 56 ++++++++++-
src/shareExtension/utils.spec.ts | 87 ++++++++++++++++
src/shareExtension/utils.ts | 6 ++
23 files changed, 441 insertions(+), 175 deletions(-)
create mode 100644 src/shareExtension/components/NotificationLabel.tsx
create mode 100644 src/shareExtension/utils.spec.ts
diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts
index 1dd241ca6..163a32b24 100644
--- a/assets/lang/strings.ts
+++ b/assets/lang/strings.ts
@@ -344,6 +344,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!',
@@ -1203,6 +1205,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!',
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/jest.config.ts b/jest.config.ts
index cca7dcd69..2582f8314 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -22,6 +22,7 @@ const untranspiledModulePatterns = [
'uuid',
'p-limit',
'yocto-queue',
+ 'mime',
];
const config: Config.InitialOptions = {
diff --git a/src/shareExtension/ShareExtensionView.android.tsx b/src/shareExtension/ShareExtensionView.android.tsx
index d3c1a7888..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 ?? [],
);
@@ -52,7 +53,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
if (status === 'unauthenticated') {
return (
-
+
);
@@ -60,7 +61,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
if (status === 'loading' || !rootFolderUuid) {
return (
-
+
@@ -69,7 +70,7 @@ const ShareExtensionView = ({ navigation, route }: RootStackScreenProps<'Android
}
return (
-
+ {
const tailwind = useTailwind();
+ const colors = useShareColors();
const { sdkReady, sharedFiles } = useShareExtension({
photosToken,
mnemonic,
@@ -77,7 +78,7 @@ const ShareExtensionView = ({
if (!sdkReady || !rootFolderId) {
return (
-
+
);
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..1f62fcdb5 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);
}
@@ -56,22 +62,25 @@ export const NewFolderModal = ({ visible, onCancel, onCreate }: NewFolderModalPr
style={[tailwind('flex-1 items-center justify-center px-5'), { backgroundColor: 'rgba(0,0,0,0.4)' }]}
onPress={handleCancel}
>
-
- {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..ed1f7c2f2
--- /dev/null
+++ b/src/shareExtension/components/NotificationLabel.tsx
@@ -0,0 +1,99 @@
+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;
+
+ return (
+
+
+
+
+ {message}
+
+ {hasAction ? (
+
+ {actionLabel}
+
+ ) : !isSuccess ? (
+
+
+
+ ) : null}
+
+
+ );
+};
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 7b5e30b7a..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';
@@ -27,6 +27,7 @@ export const UploadSuccessCard = ({
onViewInFolder,
}: UploadSuccessCardProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const shareExtensionTrans = strings.screens.ShareExtension;
const slideAnim = useRef(new Animated.Value(400)).current;
@@ -75,7 +76,8 @@ export const UploadSuccessCard = ({
@@ -84,23 +86,19 @@ export const UploadSuccessCard = ({
-
+
{renderFilePreview()}
{fileName}
{sizeAndFormat ? (
{sizeAndFormat}
@@ -109,30 +107,24 @@ export const UploadSuccessCard = ({
-
+
{shareExtensionTrans.uploadedTitle}
{shareExtensionTrans.uploadedSubtitle}
-
-
+
+
{shareExtensionTrans.viewInFolder}
diff --git a/src/shareExtension/hooks/useShareUpload.ts b/src/shareExtension/hooks/useShareUpload.ts
index 0614de3a7..a8ca275c3 100644
--- a/src/shareExtension/hooks/useShareUpload.ts
+++ b/src/shareExtension/hooks/useShareUpload.ts
@@ -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 {
@@ -231,7 +231,7 @@ export const useShareUpload = ({ onFileUploaded }: UseShareUploadOptions = {}):
actualUploadedCount++;
if (isSingleFile) {
thumbnailUriRef.current = thumbnailLocalUri;
- setThumbnailUri(thumbnailLocalUri);
+ setThumbnailUri(thumbnailLocalUri ? toDisplayUri(thumbnailLocalUri) : null);
}
} catch (error) {
const uploadErrorType = classifyError(error);
diff --git a/src/shareExtension/screens/DriveScreen.tsx b/src/shareExtension/screens/DriveScreen.tsx
index 4accc6b0d..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,
@@ -59,6 +61,7 @@ export const DriveScreen = ({
onCollisionAction,
}: DriveScreenProps) => {
const tailwind = useTailwind();
+ const colors = useShareColors();
const {
currentFolder,
folders,
@@ -78,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;
@@ -111,6 +115,7 @@ export const DriveScreen = ({
async (name: string) => {
await createFolder(name);
setShowNewFolderModal(false);
+ setFolderCreatedToast(true);
},
[createFolder],
);
@@ -132,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}
+ />
+ void;
@@ -14,7 +14,7 @@ function LoginIcon() {
);
From d8c6693114e93c850afc23ded1f25468882cef74 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Thu, 23 Apr 2026 16:13:37 +0200
Subject: [PATCH 31/32] Changed dark red colors for error notification label
---
src/shareExtension/theme.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/shareExtension/theme.ts b/src/shareExtension/theme.ts
index 26d43bad0..a97068a04 100644
--- a/src/shareExtension/theme.ts
+++ b/src/shareExtension/theme.ts
@@ -50,8 +50,8 @@ const darkColors = {
primaryBg: 'rgba(20,114,255,0.15)',
primaryBgStrong: 'rgba(20,114,255,0.20)',
red: 'rgb(255,61,51)',
- redBg: 'rgba(255,61,51,0.15)',
- redBorder: 'rgba(255,61,51,0.30)',
+ 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)',
From 58eb778ec9a1f374e7e6d220abdaf930a06b8fd3 Mon Sep 17 00:00:00 2001
From: Ramon Candel
Date: Fri, 24 Apr 2026 12:18:28 +0200
Subject: [PATCH 32/32] Implement permission revocation check and thunk for
photos backup management
---
src/navigation/TabExplorerNavigator.tsx | 7 ++-
src/store/slices/photos/index.spec.ts | 74 +++++++++++++++++++++----
src/store/slices/photos/index.ts | 16 ++++++
3 files changed, 83 insertions(+), 14 deletions(-)
diff --git a/src/navigation/TabExplorerNavigator.tsx b/src/navigation/TabExplorerNavigator.tsx
index 08786cb9e..1b1126b56 100644
--- a/src/navigation/TabExplorerNavigator.tsx
+++ b/src/navigation/TabExplorerNavigator.tsx
@@ -7,16 +7,17 @@ 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 MoveItemsModal from '../components/modals/MoveItemsModal';
-import RunOutOfStorageModal from '../components/modals/RunOutOfStorageModal';
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';
import useGetColor from '../hooks/useColor';
@@ -55,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 {
@@ -66,6 +68,7 @@ export default function TabExplorerNavigator(props: RootStackScreenProps<'TabExp
}
}
}
+
return (
{
jest.clearAllMocks();
});
- it('when store initializes, then backup is disabled with wifi-only and undetermined permission', () => {
+ test('when store initializes, then backup is disabled with wifi-only and undetermined permission', () => {
const store = makeStore();
const { enabled, networkCondition, permissionStatus } = store.getState().photos;
@@ -50,7 +51,7 @@ describe('photos slice', () => {
expect(permissionStatus).toBe('undetermined');
});
- it('when hydratePhotosStateThunk runs with persisted data, then state reflects saved values', async () => {
+ test('when hydratePhotosStateThunk runs with persisted data, then state reflects saved values', async () => {
const saved: PhotosState = { enabled: true, networkCondition: 'wifi-and-data', permissionStatus: 'granted' };
mockAsyncStorage.getItem.mockResolvedValueOnce(JSON.stringify(saved));
@@ -62,7 +63,7 @@ describe('photos slice', () => {
expect(store.getState().photos.permissionStatus).toBe('granted');
});
- it('when hydratePhotosStateThunk runs with partial data, then missing fields keep defaults', async () => {
+ test('when hydratePhotosStateThunk runs with partial data, then missing fields keep defaults', async () => {
mockAsyncStorage.getItem.mockResolvedValueOnce(JSON.stringify({ enabled: true }));
const store = makeStore();
@@ -73,7 +74,7 @@ describe('photos slice', () => {
expect(store.getState().photos.permissionStatus).toBe('undetermined');
});
- it('when hydratePhotosStateThunk runs with corrupted JSON, then all defaults are preserved', async () => {
+ test('when hydratePhotosStateThunk runs with corrupted JSON, then all defaults are preserved', async () => {
mockAsyncStorage.getItem.mockResolvedValueOnce('NOT_JSON');
const store = makeStore();
@@ -84,7 +85,7 @@ describe('photos slice', () => {
expect(store.getState().photos.permissionStatus).toBe('undetermined');
});
- it('when hydratePhotosStateThunk runs with nothing persisted, then state keeps defaults', async () => {
+ test('when hydratePhotosStateThunk runs with nothing persisted, then state keeps defaults', async () => {
mockAsyncStorage.getItem.mockResolvedValueOnce(null);
const store = makeStore();
@@ -93,7 +94,7 @@ describe('photos slice', () => {
expect(store.getState().photos.enabled).toBe(false);
});
- it('when enableBackupThunk runs and permission is granted, then backup is enabled and persisted correctly', async () => {
+ test('when enableBackupThunk runs and permission is granted, then backup is enabled and persisted correctly', async () => {
mockPermissionService.requestPermission.mockResolvedValueOnce('granted');
const store = makeStore();
@@ -106,7 +107,7 @@ describe('photos slice', () => {
expect(getPersistedState()).toMatchObject({ enabled: true, permissionStatus: 'granted' });
});
- it('when enableBackupThunk runs and permission is limited, then backup is enabled and persisted correctly', async () => {
+ test('when enableBackupThunk runs and permission is limited, then backup is enabled and persisted correctly', async () => {
mockPermissionService.requestPermission.mockResolvedValueOnce('limited');
const store = makeStore();
@@ -118,7 +119,7 @@ describe('photos slice', () => {
expect(getPersistedState()).toMatchObject({ enabled: true, permissionStatus: 'limited' });
});
- it('when enableBackupThunk runs and permission is denied, then backup stays disabled and persisted correctly', async () => {
+ test('when enableBackupThunk runs and permission is denied, then backup stays disabled and persisted correctly', async () => {
mockPermissionService.requestPermission.mockResolvedValueOnce('denied');
const store = makeStore();
@@ -130,7 +131,7 @@ describe('photos slice', () => {
expect(getPersistedState()).toMatchObject({ enabled: false, permissionStatus: 'denied' });
});
- it('when enableBackupThunk runs and permission is undetermined, then backup stays disabled', async () => {
+ test('when enableBackupThunk runs and permission is undetermined, then backup stays disabled', async () => {
mockPermissionService.requestPermission.mockResolvedValueOnce('undetermined');
const store = makeStore();
@@ -141,7 +142,7 @@ describe('photos slice', () => {
expect(store.getState().photos.permissionStatus).toBe('undetermined');
});
- it('when disableBackupThunk runs after backup was enabled, then backup is disabled and permission status is untouched', async () => {
+ test('when disableBackupThunk runs after backup was enabled, then backup is disabled and permission status is untouched', async () => {
mockPermissionService.requestPermission.mockResolvedValueOnce('granted');
const store = makeStore();
await store.dispatch(enableBackupThunk());
@@ -153,7 +154,7 @@ describe('photos slice', () => {
expect(getPersistedState()).toMatchObject({ enabled: false, permissionStatus: 'granted' });
});
- it('when setNetworkConditionThunk runs with wifi-and-data, then network condition is updated and persisted', async () => {
+ test('when setNetworkConditionThunk runs with wifi-and-data, then network condition is updated and persisted', async () => {
const store = makeStore();
await store.dispatch(setNetworkConditionThunk('wifi-and-data'));
@@ -161,7 +162,56 @@ describe('photos slice', () => {
expect(getPersistedState()).toMatchObject({ networkCondition: 'wifi-and-data' });
});
- it('when setNetworkConditionThunk runs with wifi-only after wifi-and-data, then network condition reverts and is persisted', async () => {
+ 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'));
await store.dispatch(setNetworkConditionThunk('wifi-only'));
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 }) => {