From 9c45ff03bf6425c9cbda3acc9154c31991484d74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 21:33:12 +0000 Subject: [PATCH 01/32] Bump tar from 7.5.10 to 7.5.11 Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.10 to 7.5.11. - [Release notes](https://github.com/isaacs/node-tar/releases) - [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md) - [Commits](https://github.com/isaacs/node-tar/compare/v7.5.10...v7.5.11) --- updated-dependencies: - dependency-name: tar dependency-version: 7.5.11 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 e0fa6d43e..a0b056332 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8796,9 +8796,9 @@ tailwindcss@^3.0.0: sucrase "^3.35.0" tar@^7.5.2: - version "7.5.10" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.10.tgz#2281541123f5507db38bc6eb22619f4bbaef73ad" - integrity sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw== + version "7.5.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" From 44ad1fd575aebb5d70d8d0be78a136366af012ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 14 Mar 2026 11:07:08 +0000 Subject: [PATCH 02/32] Bump undici from 6.23.0 to 6.24.1 Bumps [undici](https://github.com/nodejs/undici) from 6.23.0 to 6.24.1. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v6.23.0...v6.24.1) --- updated-dependencies: - dependency-name: undici dependency-version: 6.24.1 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 a0b056332..b1c308a78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9118,9 +9118,9 @@ undici-types@~7.16.0: integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== undici@^6.18.2: - version "6.23.0" - resolved "https://registry.yarnpkg.com/undici/-/undici-6.23.0.tgz#7953087744d9095a96f115de3140ca3828aff3a4" - integrity sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g== + version "6.24.1" + resolved "https://registry.yarnpkg.com/undici/-/undici-6.24.1.tgz#9df1425cede20b836d95634347946f79578b7e71" + integrity sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" From 6795318bd708121f1895f9b486d6715b18ecbf05 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 03:50:22 +0000 Subject: [PATCH 03/32] Bump socket.io-parser from 4.2.5 to 4.2.6 Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.5 to 4.2.6. - [Release notes](https://github.com/socketio/socket.io/releases) - [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md) - [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.5...socket.io-parser@4.2.6) --- updated-dependencies: - dependency-name: socket.io-parser dependency-version: 4.2.6 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 b1c308a78..679f5f914 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8424,9 +8424,9 @@ socket.io-client@^4.8.3: socket.io-parser "~4.2.4" socket.io-parser@~4.2.4: - version "4.2.5" - resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.5.tgz#3f41b8d369129a93268f2abecba94b5292850099" - integrity sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ== + version "4.2.6" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz#19156bf179af3931abd05260cfb1491822578a6f" + integrity sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg== dependencies: "@socket.io/component-emitter" "~3.1.0" debug "~4.4.1" From a2b7e88907edfb5bc4c16a9d8d8c34554fce314c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 21 Mar 2026 10:31:23 +0000 Subject: [PATCH 04/32] Bump flatted from 3.3.3 to 3.4.2 Bumps [flatted](https://github.com/WebReflection/flatted) from 3.3.3 to 3.4.2. - [Commits](https://github.com/WebReflection/flatted/compare/v3.3.3...v3.4.2) --- updated-dependencies: - dependency-name: flatted dependency-version: 3.4.2 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 b1c308a78..74d998587 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4821,9 +4821,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.2.9: - version "3.3.3" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" - integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== flow-enums-runtime@^0.0.6: version "0.0.6" From 35117c241e3a6a5bdfe3d8c91152f5d0fbad752d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 00:01:22 +0000 Subject: [PATCH 05/32] Bump handlebars from 4.7.8 to 4.7.9 Bumps [handlebars](https://github.com/handlebars-lang/handlebars.js) from 4.7.8 to 4.7.9. - [Release notes](https://github.com/handlebars-lang/handlebars.js/releases) - [Changelog](https://github.com/handlebars-lang/handlebars.js/blob/v4.7.9/release-notes.md) - [Commits](https://github.com/handlebars-lang/handlebars.js/compare/v4.7.8...v4.7.9) --- updated-dependencies: - dependency-name: handlebars dependency-version: 4.7.9 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 cba957682..f50ed86cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5058,9 +5058,9 @@ graphemer@^1.4.0: integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== handlebars@^4.7.8: - version "4.7.8" - resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.8.tgz#41c42c18b1be2365439188c77c6afae71c0cd9e9" - integrity sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ== + version "4.7.9" + resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.9.tgz#6f139082ab58dc4e5a0e51efe7db5ae890d56a0f" + integrity sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ== dependencies: minimist "^1.2.5" neo-async "^2.6.2" From e272b885bd0f9f6c7c6751a1fdd6651b7dffd629 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 01:34:02 +0000 Subject: [PATCH 06/32] Bump yaml from 2.8.2 to 2.8.3 Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3. - [Release notes](https://github.com/eemeli/yaml/releases) - [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3) --- updated-dependencies: - dependency-name: yaml dependency-version: 2.8.3 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 cba957682..0bc78f57a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9522,9 +9522,9 @@ yallist@^5.0.0: integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== yaml@^2.2.2, yaml@^2.6.1: - version "2.8.2" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.2.tgz#5694f25eca0ce9c3e7a9d9e00ce0ddabbd9e35c5" - integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== + version "2.8.3" + resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.8.3.tgz#a0d6bd2efb3dd03c59370223701834e60409bd7d" + integrity sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg== yargs-parser@^18.1.3: version "18.1.3" From 32fdb86097131fc6260d96127b82c7d423f6b825 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:07:59 +0000 Subject: [PATCH 07/32] Bump node-forge from 1.3.3 to 1.4.0 Bumps [node-forge](https://github.com/digitalbazaar/forge) from 1.3.3 to 1.4.0. - [Changelog](https://github.com/digitalbazaar/forge/blob/main/CHANGELOG.md) - [Commits](https://github.com/digitalbazaar/forge/compare/v1.3.3...v1.4.0) --- updated-dependencies: - dependency-name: node-forge dependency-version: 1.4.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 0bc78f57a..ad145266e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6831,9 +6831,9 @@ node-fetch@^2.7.0: whatwg-url "^5.0.0" node-forge@^1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751" - integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg== + version "1.4.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" + integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== node-int64@^0.4.0: version "0.4.0" From cb66e11f5a807c2804cbd78c8a6157f646381c47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:18:33 +0000 Subject: [PATCH 08/32] Bump sjcl from 1.0.8 to 1.0.9 Bumps [sjcl](https://github.com/bitwiseshiftleft/sjcl) from 1.0.8 to 1.0.9. - [Release notes](https://github.com/bitwiseshiftleft/sjcl/releases) - [Commits](https://github.com/bitwiseshiftleft/sjcl/commits) --- updated-dependencies: - dependency-name: sjcl dependency-version: 1.0.9 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 10d62216a..31c206c31 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8381,9 +8381,9 @@ sisteransi@^1.0.5: integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== sjcl@^1.0.3: - version "1.0.8" - resolved "https://registry.yarnpkg.com/sjcl/-/sjcl-1.0.8.tgz#f2ec8d7dc1f0f21b069b8914a41a8f236b0e252a" - integrity sha512-LzIjEQ0S0DpIgnxMEayM1rq9aGwGRG4OnZhCdjx7glTaJtf4zRfpg87ImfjSJjoW9vKpagd82McDOwbRT5kQKQ== + version "1.0.9" + resolved "https://registry.yarnpkg.com/sjcl/-/sjcl-1.0.9.tgz#650560f04c0d6d5adfa3aa8c63190a138913bb40" + integrity sha512-dWM71tkSHxe7zEZj0/COjtJdmErIxp7UMp8a6D4xx8dTTtJLc4lFL+HAX8s6lvASyQQ2iYMHwa7rhhQq7MT5MA== slash@^2.0.0: version "2.0.0" From 04f571eab74ec2507187f1a7817da03752e9caf1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 04:48:41 +0000 Subject: [PATCH 09/32] Bump @xmldom/xmldom from 0.8.11 to 0.8.12 Bumps [@xmldom/xmldom](https://github.com/xmldom/xmldom) from 0.8.11 to 0.8.12. - [Release notes](https://github.com/xmldom/xmldom/releases) - [Changelog](https://github.com/xmldom/xmldom/blob/master/CHANGELOG.md) - [Commits](https://github.com/xmldom/xmldom/compare/0.8.11...0.8.12) --- updated-dependencies: - dependency-name: "@xmldom/xmldom" dependency-version: 0.8.12 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 31c206c31..0885fa4dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2567,9 +2567,9 @@ lodash "^4" "@xmldom/xmldom@^0.8.8": - version "0.8.11" - resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.11.tgz#b79de2d67389734c57c52595f7a7305e30c2d608" - integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw== + version "0.8.12" + resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.8.12.tgz#cf488a5435fa06c7374ad1449c69cea0f823624b" + integrity sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg== "@yarnpkg/lockfile@^1.1.0": version "1.1.0" From 5567b53d9bb800461e8457aa1ea4741d3fff328a Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Wed, 1 Apr 2026 16:14:11 +0200 Subject: [PATCH 10/32] Update readme --- README.md | 77 +++++++++++++++++++++++++++---------------------------- 1 file changed, 38 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 7ccf805c9..24f5b1818 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,23 @@

Internxt

+## 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 @@ Internxt automatic - 1.8.6 + 1.8.8 contain false \ 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.8 CFBundleSignature ???? 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 @@ EXUpdatesLaunchWaitMs 0 EXUpdatesRuntimeVersion - 1.8.7 + 1.8.8 EXUpdatesURL https://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 @@ Internxt automatic - 1.8.8 + 1.8.81 contain false \ 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.81 CFBundleSignature ???? 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 @@ EXUpdatesLaunchWaitMs 0 EXUpdatesRuntimeVersion - 1.8.8 + 1.8.81 EXUpdatesURL https://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 @@ Internxt automatic - 1.8.81 + 1.8.9 contain false \ 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.9 CFBundleSignature ???? 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 @@ EXUpdatesLaunchWaitMs 0 EXUpdatesRuntimeVersion - 1.8.81 + 1.8.9 EXUpdatesURL https://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 @@ ShareExtensionBackgroundColor alpha - 1 + 0 blue - 255 + 0 green - 255 + 0 red - 255 + 0 UIAppFonts 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() { + - + - {translations.title} + + {translations.title} + @@ -53,9 +53,10 @@ export function NotSignedInScreen({ onClose, onOpenLogin }: NotSignedInScreenPro {translations.notSignedIn.subtitle} - + {translations.notSignedIn.openLogin} diff --git a/src/shareExtension/theme.ts b/src/shareExtension/theme.ts index c01f9c4c0..6784391bf 100644 --- a/src/shareExtension/theme.ts +++ b/src/shareExtension/theme.ts @@ -1,4 +1,4 @@ -import { Platform } from 'react-native'; +import { Platform, useColorScheme } from 'react-native'; /** * Used for icon colors, fonts, and values not expressed as tailwind classes (e.g. primaryDisabled). @@ -21,7 +21,7 @@ export const fontStyles = { }, } as const; -export const colors = { +const lightColors = { primary: 'rgba(0,102,255)', primaryDisabled: 'rgba(0,102,255,0.5)', primaryBg: 'rgba(0,102,255,0.08)', @@ -40,4 +40,56 @@ export const colors = { gray5: 'rgba(243,243,248)', gray1: 'rgba(249,249,252)', surface: 'rgba(255,255,255)', + white: 'rgb(255,255,255)', } as const; + +const darkColors = { + primary: 'rgb(20,114,255)', + primaryDisabled: 'rgba(20,114,255,0.5)', + primaryBg: 'rgba(20,114,255,0.15)', + primaryBgStrong: 'rgba(20,114,255,0.20)', + red: 'rgb(255,61,51)', + redBg: 'rgba(255,61,51,0.15)', + redBorder: 'rgba(255,61,51,0.30)', + successGreen: 'rgb(72,208,106)', + successBg: 'rgba(72,208,106,0.15)', + gray100: 'rgb(249,249,252)', + gray80: 'rgb(229,229,235)', + gray60: 'rgb(199,199,205)', + gray40: 'rgb(142,142,148)', + gray20: 'rgb(72,72,75)', + gray10: 'rgb(58,58,59)', + gray5: 'rgb(44,44,48)', + gray1: 'rgb(24,24,27)', + surface: 'rgb(17,17,17)', + white: 'rgb(255,255,255)', +} as const; + +export interface ShareColors { + primary: string; + primaryDisabled: string; + primaryBg: string; + primaryBgStrong: string; + red: string; + redBg: string; + redBorder: string; + successGreen: string; + successBg: string; + gray100: string; + gray80: string; + gray60: string; + gray40: string; + gray20: string; + gray10: string; + gray5: string; + gray1: string; + surface: string; + white: string; +} + +export const useShareColors = (): ShareColors => { + const scheme = useColorScheme(); + return scheme === 'dark' ? darkColors : lightColors; +}; + +export const colors = lightColors; diff --git a/src/shareExtension/utils.spec.ts b/src/shareExtension/utils.spec.ts new file mode 100644 index 000000000..d170bb7c1 --- /dev/null +++ b/src/shareExtension/utils.spec.ts @@ -0,0 +1,87 @@ +import { formatBytes, getFileExtension, getFileNameWithoutExtension, getMimeTypeFromUri, toDisplayUri } from './utils'; + +describe('toDisplayUri', () => { + test('when given a raw filesystem path, then it prepends file://', () => { + expect(toDisplayUri('/data/user/0/com.internxt/cache/thumb.jpg')).toBe( + 'file:///data/user/0/com.internxt/cache/thumb.jpg', + ); + }); + + test('when given a file:// URI, then it returns it unchanged', () => { + expect(toDisplayUri('file:///data/user/0/com.internxt/cache/thumb.jpg')).toBe( + 'file:///data/user/0/com.internxt/cache/thumb.jpg', + ); + }); + + test('when given a content:// URI, then it returns it unchanged', () => { + expect(toDisplayUri('content://media/external/images/media/42')).toBe('content://media/external/images/media/42'); + }); + + test('when given an https:// URI, then it returns it unchanged', () => { + expect(toDisplayUri('https://example.com/image.jpg')).toBe('https://example.com/image.jpg'); + }); + + test('when given an http:// URI, then it returns it unchanged', () => { + expect(toDisplayUri('http://example.com/image.jpg')).toBe('http://example.com/image.jpg'); + }); +}); + +describe('getFileExtension', () => { + test('when given a filename with uppercase extension, then it returns it lowercased', () => { + expect(getFileExtension('photo.JPG')).toBe('jpg'); + }); + + test('when given a filename with no extension, then it returns empty string', () => { + expect(getFileExtension('README')).toBe(''); + }); + + test('when given null, then it returns empty string', () => { + expect(getFileExtension(null)).toBe(''); + }); + + test('when given a dotted filename, then it returns the last extension', () => { + expect(getFileExtension('archive.tar.gz')).toBe('gz'); + }); + + test('when given a hidden file (leading dot only), then it returns empty string', () => { + expect(getFileExtension('.gitignore')).toBe(''); + }); +}); + +describe('getFileNameWithoutExtension', () => { + test('when given a filename with extension, then it strips the extension', () => { + expect(getFileNameWithoutExtension('photo.jpg')).toBe('photo'); + }); + + test('when given a filename with no extension, then it returns the full name', () => { + expect(getFileNameWithoutExtension('README')).toBe('README'); + }); + + test('when given null, then it returns the fallback string', () => { + expect(getFileNameWithoutExtension(null)).toBe('file'); + }); + + test('when given a dotted filename, then it strips only the last extension', () => { + expect(getFileNameWithoutExtension('archive.tar.gz')).toBe('archive.tar'); + }); +}); + +describe('getMimeTypeFromUri', () => { + test('when given a file:// URI with known extension, then it returns the MIME type', () => { + expect(getMimeTypeFromUri('file:///storage/emulated/0/DCIM/photo.jpg')).toBe('image/jpeg'); + }); + + test('when given a content:// URI with a filename segment, then it returns the MIME type', () => { + expect(getMimeTypeFromUri('content://media/external/images/media/document.pdf')).toBe('application/pdf'); + }); + + test('when given a URI with no recognizable extension, then it returns null', () => { + expect(getMimeTypeFromUri('content://media/external/images/media/42')).toBeNull(); + }); +}); + +describe('formatBytes', () => { + test('when given a byte count, then it returns a non-empty human-readable string', () => { + expect(formatBytes(1024)).toBeTruthy(); + }); +}); diff --git a/src/shareExtension/utils.ts b/src/shareExtension/utils.ts index 2e281bc24..19cc31fa7 100644 --- a/src/shareExtension/utils.ts +++ b/src/shareExtension/utils.ts @@ -9,6 +9,12 @@ import { SharedFile, UploadErrorType } from './types'; dayjs.extend(relativeTime); +/** + * Normalizes a local filesystem path to a URI suitable for React Native's Image component. + */ +export const toDisplayUri = (path: string): string => + path.startsWith('file://') || path.startsWith('content://') || path.startsWith('http') ? path : `file://${path}`; + export const formatDate = (dateStr: string): string => { const date = dayjs(dateStr); const weekDays = 7; From 839ac3061ff2f8e5ff27009b3a999af4f257222c Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 23 Apr 2026 09:39:22 +0200 Subject: [PATCH 27/32] migrated theme preference to secure storage to use in shared keychain and update share extension --- ios/Internxt.xcodeproj/project.pbxproj | 228 +++++++++--------- ios/Internxt/AppDelegate.swift | 6 + .../ShareExtensionViewController.swift | 13 +- src/contexts/Theme/Theme.context.tsx | 12 +- src/services/AsyncStorageService.ts | 2 +- src/shareExtension/ShareExtensionView.ios.tsx | 25 +- src/shareExtension/ShareThemeProvider.tsx | 16 ++ .../screens/NotSignedInScreen.tsx | 54 +++-- src/shareExtension/theme.ts | 7 +- 9 files changed, 204 insertions(+), 159 deletions(-) create mode 100644 src/shareExtension/ShareThemeProvider.tsx diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj index eea9f361c..53f06bc04 100644 --- a/ios/Internxt.xcodeproj/project.pbxproj +++ b/ios/Internxt.xcodeproj/project.pbxproj @@ -11,9 +11,9 @@ 1818AB341F31CC033FFF750B /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD55E1E535AECF7719BDD772 /* ExpoModulesProvider.swift */; }; 18D7DE9587FA2B2314575ED6 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */; }; 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 */; }; + 7677A9D0CC2D34CF895A907E /* libPods-Internxt.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 944651400FAC80AAD61017EE /* libPods-Internxt.a */; }; + 7A61A118D20D0540EF70FE74 /* libPods-InternxtShareExtension.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 546A3CDB7E733C0B5DA4C994 /* libPods-InternxtShareExtension.a */; }; 7D373856C83A43879BFBE604 /* InternxtShareExtension.appex in Copy Files */ = {isa = PBXBuildFile; fileRef = 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */; }; 8F8148045BC14A539BD1917D /* ShareExtensionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A6F72E1B98D469883DAFB30 /* ShareExtensionViewController.swift */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; @@ -63,18 +63,18 @@ 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 = ""; }; - 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 = ""; }; + 546A3CDB7E733C0B5DA4C994 /* libPods-InternxtShareExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-InternxtShareExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 6906479D930862F73C50C0FE /* Pods-InternxtShareExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.debug.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.debug.xcconfig"; sourceTree = ""; }; 7F5486528A7D46DD91A7910F /* InternxtShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; fileEncoding = 9; includeInIndex = 0; path = InternxtShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 82D1B734EEAF175A4C48C4AC /* Pods-Internxt.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.release.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.release.xcconfig"; sourceTree = ""; }; + 944651400FAC80AAD61017EE /* libPods-Internxt.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Internxt.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 9616C09E46C26A3E032F9B0B /* Pods-InternxtShareExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-InternxtShareExtension.release.xcconfig"; path = "Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension.release.xcconfig"; sourceTree = ""; }; A4921399A370F5CE6EE7EA0F /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-InternxtShareExtension/ExpoModulesProvider.swift"; sourceTree = ""; }; + A7EBA4C496DAF1149115FA8A /* Pods-Internxt.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Internxt.debug.xcconfig"; path = "Target Support Files/Pods-Internxt/Pods-Internxt.debug.xcconfig"; sourceTree = ""; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Internxt/SplashScreen.storyboard; sourceTree = ""; }; - 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 = ""; }; - 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 = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; @@ -87,7 +87,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 353DD4AD43EF923515032E17 /* libPods-Internxt.a in Frameworks */, + 7677A9D0CC2D34CF895A907E /* libPods-Internxt.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -95,7 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 61F2D7A88201F06723D33BDC /* libPods-InternxtShareExtension.a in Frameworks */, + 7A61A118D20D0540EF70FE74 /* libPods-InternxtShareExtension.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -122,8 +122,8 @@ isa = PBXGroup; children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, - AE464089D2AF26E8CAEEC6CB /* libPods-Internxt.a */, - BBED6BCCDD72C0D72F796F82 /* libPods-InternxtShareExtension.a */, + 944651400FAC80AAD61017EE /* libPods-Internxt.a */, + 546A3CDB7E733C0B5DA4C994 /* libPods-InternxtShareExtension.a */, ); name = Frameworks; sourceTree = ""; @@ -149,10 +149,10 @@ 808723478BA906DED7CCCEE0 /* Pods */ = { isa = PBXGroup; children = ( - C0F415F987B8A59D7CD72A59 /* Pods-Internxt.debug.xcconfig */, - C00B75BF3D056BA881E694AC /* Pods-Internxt.release.xcconfig */, - 63B48B926126B5434D8744F8 /* Pods-InternxtShareExtension.debug.xcconfig */, - DD6CED1DD255C7CBCA0B6717 /* Pods-InternxtShareExtension.release.xcconfig */, + A7EBA4C496DAF1149115FA8A /* Pods-Internxt.debug.xcconfig */, + 82D1B734EEAF175A4C48C4AC /* Pods-Internxt.release.xcconfig */, + 6906479D930862F73C50C0FE /* Pods-InternxtShareExtension.debug.xcconfig */, + 9616C09E46C26A3E032F9B0B /* Pods-InternxtShareExtension.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -222,7 +222,7 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Internxt" */; buildPhases = ( - 9749A9A2F46DB1E7F4196AEC /* [CP] Check Pods Manifest.lock */, + 01004B7AC75BAD5D7DF0705D /* [CP] Check Pods Manifest.lock */, C23BF8EF9D9B379F6EADAAE0 /* [Expo] Configure project */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, @@ -230,8 +230,8 @@ 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, DDE0311FCC774124B044B69C /* Embed Foundation Extensions */, EF531C08E8F54B18956A8C0E /* Copy Files */, - A8138DEFA393F6FFB394E7B5 /* [CP] Embed Pods Frameworks */, - 4B3A95EC80E71334D4D0CAD2 /* [CP] Copy Pods Resources */, + 8D5F24CE3C0E66506BCE66EA /* [CP] Embed Pods Frameworks */, + 712F587FF1A539C0A665036D /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -247,14 +247,14 @@ isa = PBXNativeTarget; buildConfigurationList = DDB0A9F040BE45D9929C1B2D /* Build configuration list for PBXNativeTarget "InternxtShareExtension" */; buildPhases = ( - FF344C830A01D2CD57C3053D /* [CP] Check Pods Manifest.lock */, + 31E0BC980E2795E876261F0E /* [CP] Check Pods Manifest.lock */, 90134DAC718F4FE0B3AB7B91 /* Start Packager */, BEAE0DA94817B237B864F997 /* [Expo] Configure project */, 3495BDAE4F75461EBD6FFF8F /* Sources */, 3ABC9B4F4B9D448B9797EBD7 /* Frameworks */, 2F5766E756C84914803E7082 /* Resources */, AA9E71431FBB401A8272B8C4 /* Bundle React Native code and images */, - E0007E640EC3051DD157AAB4 /* [CP] Copy Pods Resources */, + 76428B42261F4435FAF0963A /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -343,7 +343,51 @@ shellPath = /bin/sh; shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n"; }; - 4B3A95EC80E71334D4D0CAD2 /* [CP] Copy Pods Resources */ = { + 01004B7AC75BAD5D7DF0705D /* [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; + }; + 31E0BC980E2795E876261F0E /* [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; + }; + 712F587FF1A539C0A665036D /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -389,63 +433,83 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 90134DAC718F4FE0B3AB7B91 /* Start Packager */ = { + 76428B42261F4435FAF0963A /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/ReactNativeFs/RNFS_PrivacyInfo.bundle", + "${PODS_CONFIGURATION_BUILD_DIR}/react-native-blob-util/ReactNativeBlobUtilPrivacyInfo.bundle", ); - name = "Start Packager"; + name = "[CP] Copy Pods Resources"; outputPaths = ( + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNFS_PrivacyInfo.bundle", + "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReactNativeBlobUtilPrivacyInfo.bundle", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export RCT_METRO_PORT=\"${RCT_METRO_PORT:=8081}\"\necho \"export RCT_METRO_PORT=${RCT_METRO_PORT}\" > \"${SRCROOT}/../node_modules/react-native/scripts/.packager.env\"\nif [ -z \"${RCT_NO_LAUNCH_PACKAGER+xxx}\" ] ; then\n if nc -w 5 -z localhost ${RCT_METRO_PORT} ; then\n if ! curl -s \"http://localhost:${RCT_METRO_PORT}/status\" | grep -q \"packager-status:running\" ; then\n echo \"Port ${RCT_METRO_PORT} already in use, packager is either not running or not running correctly\"\n exit 2\n fi\n else\n open \"$SRCROOT/../node_modules/react-native/scripts/launchPackager.command\" || echo \"Can't start packager automatically\"\n fi\nfi\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InternxtShareExtension/Pods-InternxtShareExtension-resources.sh\"\n"; + showEnvVarsInLog = 0; }; - 9749A9A2F46DB1E7F4196AEC /* [CP] Check Pods Manifest.lock */ = { + 8D5F24CE3C0E66506BCE66EA /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/React-Core-prebuilt/React.framework/React", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/ReactNativeDependencies/ReactNativeDependencies.framework/ReactNativeDependencies", + "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", ); + name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Internxt-checkManifestLockResult.txt", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/React.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ReactNativeDependencies.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Internxt/Pods-Internxt-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - A8138DEFA393F6FFB394E7B5 /* [CP] Embed Pods Frameworks */ = { + 90134DAC718F4FE0B3AB7B91 /* Start Packager */ = { 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"; + name = "Start Packager"; 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; + 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"; }; AA9E71431FBB401A8272B8C4 /* Bundle React Native code and images */ = { isa = PBXShellScriptBuildPhase; @@ -509,70 +573,6 @@ 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 = 63B48B926126B5434D8744F8 /* Pods-InternxtShareExtension.debug.xcconfig */; + baseConfigurationReference = 6906479D930862F73C50C0FE /* Pods-InternxtShareExtension.debug.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = InternxtShareExtension/InternxtShareExtension.entitlements; @@ -638,7 +638,7 @@ }; 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C0F415F987B8A59D7CD72A59 /* Pods-Internxt.debug.xcconfig */; + baseConfigurationReference = A7EBA4C496DAF1149115FA8A /* Pods-Internxt.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -677,7 +677,7 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C00B75BF3D056BA881E694AC /* Pods-Internxt.release.xcconfig */; + baseConfigurationReference = 82D1B734EEAF175A4C48C4AC /* Pods-Internxt.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; @@ -828,7 +828,7 @@ }; B247FB85ACF44D11B7509F34 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DD6CED1DD255C7CBCA0B6717 /* Pods-InternxtShareExtension.release.xcconfig */; + baseConfigurationReference = 9616C09E46C26A3E032F9B0B /* Pods-InternxtShareExtension.release.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = InternxtShareExtension/InternxtShareExtension.entitlements; diff --git a/ios/Internxt/AppDelegate.swift b/ios/Internxt/AppDelegate.swift index 0956651af..574040176 100644 --- a/ios/Internxt/AppDelegate.swift +++ b/ios/Internxt/AppDelegate.swift @@ -64,6 +64,12 @@ public class AppDelegate: ExpoAppDelegate { deleteFromSharedKeychain(key: "shared_bridgeUser", accessGroup: sharedGroup) deleteFromSharedKeychain(key: "shared_userId", accessGroup: sharedGroup) } + + if privateKeychainItemExists(key: "themePreference") { + copyToSharedKeychain(privateKey: "themePreference", sharedKey: "shared_themePreference", accessGroup: sharedGroup) + } else { + deleteFromSharedKeychain(key: "shared_themePreference", accessGroup: sharedGroup) + } } private func privateKeychainItemExists(key: String) -> Bool { diff --git a/ios/InternxtShareExtension/ShareExtensionViewController.swift b/ios/InternxtShareExtension/ShareExtensionViewController.swift index 182f8e6f2..3d3e77188 100644 --- a/ios/InternxtShareExtension/ShareExtensionViewController.swift +++ b/ios/InternxtShareExtension/ShareExtensionViewController.swift @@ -112,12 +112,13 @@ class ShareExtensionViewController: UIViewController { var initialProps = sharedData ?? [:] // ── Internxt: inject auth state from Keychain ─────────────────────────── if let sharedGroup = Bundle.main.object(forInfoDictionaryKey: "SharedKeychainGroup") as? String { - initialProps["photosToken"] = readFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup) - initialProps["mnemonic"] = readFromSharedKeychainStripped(key: "shared_mnemonic", accessGroup: sharedGroup) - initialProps["rootFolderId"] = readFromSharedKeychainStripped(key: "shared_rootFolderId", accessGroup: sharedGroup) - initialProps["bucket"] = readFromSharedKeychainStripped(key: "shared_bucket", accessGroup: sharedGroup) - initialProps["bridgeUser"] = readFromSharedKeychainStripped(key: "shared_bridgeUser", accessGroup: sharedGroup) - initialProps["userId"] = readFromSharedKeychainStripped(key: "shared_userId", accessGroup: sharedGroup) + initialProps["photosToken"] = readFromSharedKeychain(key: "shared_photosToken", accessGroup: sharedGroup) + initialProps["mnemonic"] = readFromSharedKeychainStripped(key: "shared_mnemonic", accessGroup: sharedGroup) + initialProps["rootFolderId"] = readFromSharedKeychainStripped(key: "shared_rootFolderId", accessGroup: sharedGroup) + initialProps["bucket"] = readFromSharedKeychainStripped(key: "shared_bucket", accessGroup: sharedGroup) + initialProps["bridgeUser"] = readFromSharedKeychainStripped(key: "shared_bridgeUser", accessGroup: sharedGroup) + initialProps["userId"] = readFromSharedKeychainStripped(key: "shared_userId", accessGroup: sharedGroup) + initialProps["themePreference"] = readFromSharedKeychainStripped(key: "shared_themePreference", accessGroup: sharedGroup) } // ── From expo-share-extension library ────────────────────────────────── let currentBounds = self.view.bounds diff --git a/src/contexts/Theme/Theme.context.tsx b/src/contexts/Theme/Theme.context.tsx index e280029d2..f836fbd74 100644 --- a/src/contexts/Theme/Theme.context.tsx +++ b/src/contexts/Theme/Theme.context.tsx @@ -1,5 +1,6 @@ import asyncStorageService from '@internxt-mobile/services/AsyncStorageService'; import { logger } from '@internxt-mobile/services/common'; +import secureStorageService from '@internxt-mobile/services/SecureStorageService'; import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Appearance, NativeEventSubscription } from 'react-native'; @@ -22,10 +23,16 @@ interface ThemeProviderProps { */ const loadThemePreference = async (): Promise => { try { - const savedTheme = await asyncStorageService.getThemePreference(); - logger.info(`Saved theme from storage: ${savedTheme}`); + // SecureStorage is the source of truth after migration, was added to share with iOS in the keychain + const secureTheme = await secureStorageService.getItem('themePreference'); + if (secureTheme === 'light' || secureTheme === 'dark') { + logger.info(`Loaded theme from SecureStorage: ${secureTheme}`); + return secureTheme; + } + const savedTheme = await asyncStorageService.getThemePreference(); if (savedTheme) { + logger.info(`Loaded theme from AsyncStorage: ${savedTheme}`); return savedTheme; } @@ -135,6 +142,7 @@ export const ThemeProvider: React.FC = ({ children }) => { applyTheme(newTheme, setThemeState); await asyncStorageService.saveThemePreference(newTheme); + secureStorageService.setItem('themePreference', newTheme).catch(() => undefined); setTimeout(() => { isSettingThemeRef.current = false; diff --git a/src/services/AsyncStorageService.ts b/src/services/AsyncStorageService.ts index 67e33828a..860de17ec 100644 --- a/src/services/AsyncStorageService.ts +++ b/src/services/AsyncStorageService.ts @@ -4,7 +4,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { AsyncStorageKey } from '../types'; import secureStorageService from './SecureStorageService'; -const SENSITIVE_KEYS = [AsyncStorageKey.Token, AsyncStorageKey.PhotosToken, AsyncStorageKey.User]; +const SENSITIVE_KEYS = [AsyncStorageKey.Token, AsyncStorageKey.PhotosToken, AsyncStorageKey.User, AsyncStorageKey.ThemePreference]; class AsyncStorageService { private isSensitiveKey(key: AsyncStorageKey): boolean { diff --git a/src/shareExtension/ShareExtensionView.ios.tsx b/src/shareExtension/ShareExtensionView.ios.tsx index c20f77f75..208abc30a 100644 --- a/src/shareExtension/ShareExtensionView.ios.tsx +++ b/src/shareExtension/ShareExtensionView.ios.tsx @@ -7,6 +7,7 @@ import { useShareExtension } from './hooks/useShareExtension.ios'; import { useShareUpload } from './hooks/useShareUpload'; import { DriveScreen } from './screens/DriveScreen'; import { NotSignedInScreen } from './screens/NotSignedInScreen'; +import { ShareThemeProvider } from './ShareThemeProvider'; import { useShareColors } from './theme'; interface ShareExtensionProps { @@ -16,6 +17,7 @@ interface ShareExtensionProps { bucket?: string; bridgeUser?: string; userId?: string; + themePreference?: 'light' | 'dark'; files?: string[]; images?: string[]; videos?: string[]; @@ -30,6 +32,7 @@ const ShareExtensionView = ({ bucket, bridgeUser, userId, + themePreference, files, images, videos, @@ -72,19 +75,13 @@ const ShareExtensionView = ({ close(); }, []); - if (!photosToken) { - return openHostApp(AppPaths.signIn())} />; - } - - if (!sdkReady || !rootFolderId) { - return ( - - - - ); - } - - return ( + const content = !photosToken ? ( + openHostApp(AppPaths.signIn())} /> + ) : !sdkReady || !rootFolderId ? ( + + + + ) : ( ); + + return {content}; }; export default ShareExtensionView; diff --git a/src/shareExtension/ShareThemeProvider.tsx b/src/shareExtension/ShareThemeProvider.tsx new file mode 100644 index 000000000..270bf88bd --- /dev/null +++ b/src/shareExtension/ShareThemeProvider.tsx @@ -0,0 +1,16 @@ +import { createContext, useContext, type ReactNode } from 'react'; + +export type ThemePreference = 'light' | 'dark' | null | undefined; + +export const ShareThemeContext = createContext(undefined); + +interface ShareThemeProviderProps { + themePreference: ThemePreference; + children: ReactNode; +} + +export const ShareThemeProvider = ({ themePreference, children }: ShareThemeProviderProps) => ( + {children} +); + +export const useShareThemeContext = (): ThemePreference => useContext(ShareThemeContext); diff --git a/src/shareExtension/screens/NotSignedInScreen.tsx b/src/shareExtension/screens/NotSignedInScreen.tsx index bc04b8694..38c0633b2 100644 --- a/src/shareExtension/screens/NotSignedInScreen.tsx +++ b/src/shareExtension/screens/NotSignedInScreen.tsx @@ -2,28 +2,26 @@ import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native import Svg, { Path } from 'react-native-svg'; import { useTailwind } from 'tailwind-rn'; import strings from '../../../assets/lang/strings'; -import { colors as staticColors, fontStyles, useShareColors } from '../theme'; +import { fontStyles, useShareColors } from '../theme'; interface NotSignedInScreenProps { readonly onClose: () => void; readonly onOpenLogin: () => void; } -function LoginIcon() { - return ( - - - - ); -} +const LoginIcon = ({ color }: { color: string }) => ( + + + +); -export function NotSignedInScreen({ onClose, onOpenLogin }: NotSignedInScreenProps) { +export const NotSignedInScreen = ({ onClose, onOpenLogin }: NotSignedInScreenProps) => { const tailwind = useTailwind(); const colors = useShareColors(); const translations = strings.screens.ShareExtension; @@ -31,7 +29,10 @@ export function NotSignedInScreen({ onClose, onOpenLogin }: NotSignedInScreenPro return ( - + {translations.title} - + - + {translations.notSignedIn.openLogin} ); -} +}; diff --git a/src/shareExtension/theme.ts b/src/shareExtension/theme.ts index 6784391bf..26d43bad0 100644 --- a/src/shareExtension/theme.ts +++ b/src/shareExtension/theme.ts @@ -1,4 +1,5 @@ import { Platform, useColorScheme } from 'react-native'; +import { useShareThemeContext } from './ShareThemeProvider'; /** * Used for icon colors, fonts, and values not expressed as tailwind classes (e.g. primaryDisabled). @@ -88,8 +89,10 @@ export interface ShareColors { } export const useShareColors = (): ShareColors => { - const scheme = useColorScheme(); - return scheme === 'dark' ? darkColors : lightColors; + const themePreference = useShareThemeContext(); + const systemScheme = useColorScheme(); + const theme = themePreference ?? systemScheme; + return theme === 'dark' ? darkColors : lightColors; }; export const colors = lightColors; From 45831388950e7a7af957005c2bd2f46980432109 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 23 Apr 2026 10:03:08 +0200 Subject: [PATCH 28/32] Fix sonar cloud issues --- src/shareExtension/ShareExtensionView.ios.tsx | 53 ++++++++++--------- .../components/NewFolderModal.tsx | 8 ++- .../components/NotificationLabel.tsx | 30 +++++++---- 3 files changed, 56 insertions(+), 35 deletions(-) diff --git a/src/shareExtension/ShareExtensionView.ios.tsx b/src/shareExtension/ShareExtensionView.ios.tsx index 208abc30a..79229ae7d 100644 --- a/src/shareExtension/ShareExtensionView.ios.tsx +++ b/src/shareExtension/ShareExtensionView.ios.tsx @@ -75,30 +75,35 @@ const ShareExtensionView = ({ close(); }, []); - const content = !photosToken ? ( - openHostApp(AppPaths.signIn())} /> - ) : !sdkReady || !rootFolderId ? ( - - - - ) : ( - - ); + let content; + if (photosToken && sdkReady && rootFolderId) { + content = ( + + ); + } else if (photosToken) { + content = ( + + + + ); + } else { + content = openHostApp(AppPaths.signIn())} />; + } return {content}; }; diff --git a/src/shareExtension/components/NewFolderModal.tsx b/src/shareExtension/components/NewFolderModal.tsx index 1f62fcdb5..a4020135f 100644 --- a/src/shareExtension/components/NewFolderModal.tsx +++ b/src/shareExtension/components/NewFolderModal.tsx @@ -56,6 +56,12 @@ export const NewFolderModal = ({ visible, onCancel, onCreate }: NewFolderModalPr const handleFocus = useCallback(() => setFocused(true), []); const handleBlur = useCallback(() => setFocused(false), []); + const inputBorderColor = () => { + if (error) return colors.red; + if (focused) return colors.primary; + return colors.gray20; + }; + return ( diff --git a/src/shareExtension/components/NotificationLabel.tsx b/src/shareExtension/components/NotificationLabel.tsx index ed1f7c2f2..b620fb5bc 100644 --- a/src/shareExtension/components/NotificationLabel.tsx +++ b/src/shareExtension/components/NotificationLabel.tsx @@ -61,6 +61,21 @@ export const NotificationLabel = ({ const Icon = isSuccess ? CheckCircleIcon : WarningCircleIcon; const hasAction = isSuccess && !!actionLabel && !!onAction; + let actionComponent; + if (hasAction) { + actionComponent = ( + + {actionLabel} + + ); + } else { + actionComponent = ( + + + + ); + } + return ( - + {message} - {hasAction ? ( - - {actionLabel} - - ) : !isSuccess ? ( - - - - ) : null} + {actionComponent} ); From 1500bd27473eef0a5bd9189f2698f482bcf82f58 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 23 Apr 2026 10:06:30 +0200 Subject: [PATCH 29/32] Bump versionCode to 123 --- android/app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index 142928715..21ec2d9f7 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -93,7 +93,7 @@ android { applicationId 'com.internxt.cloud' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 122 + versionCode 123 versionName "1.9.0" buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\"" From 286df83e3ee621b94c2710f632ee98eaad858102 Mon Sep 17 00:00:00 2001 From: Ramon Candel Date: Thu, 23 Apr 2026 10:14:24 +0200 Subject: [PATCH 30/32] Replaced View with Pressable in NewFolderModal for improved touch handling --- src/shareExtension/components/NewFolderModal.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shareExtension/components/NewFolderModal.tsx b/src/shareExtension/components/NewFolderModal.tsx index a4020135f..d10d9fcf4 100644 --- a/src/shareExtension/components/NewFolderModal.tsx +++ b/src/shareExtension/components/NewFolderModal.tsx @@ -68,7 +68,7 @@ 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} @@ -134,7 +134,7 @@ export const NewFolderModal = ({ visible, onCancel, onCreate }: NewFolderModalPr )} - + ); 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 }) => {