diff --git a/__mocks__/@rudderstack/rudder-sdk-react-native.ts b/__mocks__/@rudderstack/rudder-sdk-react-native.ts
deleted file mode 100644
index a9d9c7f38..000000000
--- a/__mocks__/@rudderstack/rudder-sdk-react-native.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-jest.mock('@rudderstack/rudder-sdk-react-native', () => {
- return {};
-});
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 445309bcd..955b3b010 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -88,8 +88,8 @@ android {
applicationId 'com.internxt.cloud'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
- versionCode 111
- versionName "1.8.2"
+ versionCode 112
+ versionName "1.8.3"
buildConfigField("boolean", "REACT_NATIVE_UNSTABLE_USE_RUNTIME_SCHEDULER_ALWAYS", (findProperty("reactNative.unstable_useRuntimeSchedulerAlways") ?: true).toString())
missingDimensionStrategy "react-native-capture-protection", "fullMediaCapture"
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4962e729e..8fa7a8591 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -31,6 +31,7 @@
+
@@ -40,6 +41,7 @@
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
index 29fd0c389..75bc0256a 100644
--- a/android/app/src/main/res/values/strings.xml
+++ b/android/app/src/main/res/values/strings.xml
@@ -2,6 +2,6 @@
Internxt
cover
false
- 1.8.2
+ 1.8.3
automatic
\ No newline at end of file
diff --git a/app.config.ts b/app.config.ts
index 16ddc98a8..395babdaf 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -16,7 +16,7 @@ const RELEASE_ID = `${packageVersion} (${env[stage].APP_BUILD_NUMBER}) - ${stage
const appConfig: ExpoConfig & { extra: AppEnv & { NODE_ENV: AppStage; RELEASE_ID: string } } = {
name: 'Internxt',
- scheme: 'inxt',
+ scheme: 'internxt',
slug: 'drive-mobile',
version: packageVersion,
orientation: 'portrait',
@@ -50,6 +50,11 @@ const appConfig: ExpoConfig & { extra: AppEnv & { NODE_ENV: AppStage; RELEASE_ID
NSPhotoLibraryAddUsageDescription: 'Allow $(PRODUCT_NAME) to save/download photos from the storage service',
NSPhotoLibraryUsageDescription:
'Allow $(PRODUCT_NAME) to access your photos to sync your device camera roll with our Photos cloud service',
+ CFBundleURLTypes: [
+ {
+ CFBundleURLSchemes: ['internxt', 'inxt'],
+ },
+ ],
},
},
android: {
@@ -74,6 +79,9 @@ const appConfig: ExpoConfig & { extra: AppEnv & { NODE_ENV: AppStage; RELEASE_ID
action: 'VIEW',
category: ['BROWSABLE', 'DEFAULT'],
data: [
+ {
+ scheme: 'internxt',
+ },
{
scheme: 'inxt',
},
diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts
index e2b5f6bed..d8a8097fb 100644
--- a/assets/lang/strings.ts
+++ b/assets/lang/strings.ts
@@ -122,9 +122,16 @@ const strings = new LocalizedStrings({
SignInScreen: {
title: 'Login',
forgot: 'Forgot your password?',
- no_register: 'Don’t have an Internxt account?',
+ no_register: "Don't have an Internxt account?",
register: 'Get started',
back: 'Back to login',
+ errorOpeningLink: 'Error opening login link',
+ },
+ WebLoginScreen: {
+ processing: 'Completing sign in...',
+ success: 'Sign in successful!',
+ authenticationFailed: 'Authentication failed',
+ missingParameters: 'Missing required authentication parameters',
},
SignUpScreen: {
title: 'Create account',
@@ -148,6 +155,7 @@ const strings = new LocalizedStrings({
create_account_title: 'Create an Internxt account',
acceptTermsAndConditions: 'Accept terms, conditions and privacy policy',
alreadyHaveAccount: 'Already have an account?',
+ errorOpeningLink: 'Error opening signup link',
},
home: {
title: 'Home',
@@ -893,6 +901,13 @@ const strings = new LocalizedStrings({
no_register: '¿No tienes una cuenta de Internxt?',
register: 'Regístrate',
back: 'Iniciar sesión',
+ errorOpeningLink: 'Error al abrir el link de autenticación',
+ },
+ WebLoginScreen: {
+ processing: 'Completando inicio de sesión...',
+ success: '¡Sesión iniciada con éxito!',
+ authenticationFailed: 'Autenticación fallida',
+ missingParameters: 'Faltan parámetros de autenticación requeridos',
},
SignUpScreen: {
title: 'Crear cuenta',
@@ -917,6 +932,7 @@ const strings = new LocalizedStrings({
create_account_title: 'Crear cuenta',
acceptTermsAndConditions: 'Aceptar términos, condiciones y política de privacidad',
alreadyHaveAccount: '¿Ya tienes una cuenta?',
+ errorOpeningLink: 'Error al abrir el link de registro',
},
home: {
title: 'Inicio',
diff --git a/babel.config.js b/babel.config.js
index dbac0af77..424ca3cf4 100644
--- a/babel.config.js
+++ b/babel.config.js
@@ -18,6 +18,7 @@ module.exports = function (api) {
'@internxt-mobile/services': './src/services',
'@internxt-mobile/types': './src/types',
'@internxt-mobile/useCases': './src/useCases',
+ '@internxt-mobile/contexts': './src/contexts',
},
},
],
diff --git a/env/.env.example.json b/env/.env.example.json
index a78707302..e224d0946 100644
--- a/env/.env.example.json
+++ b/env/.env.example.json
@@ -14,7 +14,6 @@
"MAGIC_SALT": "",
"RECAPTCHA_V3": "",
"DATAPLANE_URL": "",
- "ANALYTICS_WRITE_KEY": "",
"SENTRY_DSN": "",
"SENTRY_ORGANIZATION": "",
"SENTRY_PROJECT": "",
diff --git a/ios/Internxt.xcodeproj/project.pbxproj b/ios/Internxt.xcodeproj/project.pbxproj
index a5b382c70..b3a347cd3 100644
--- a/ios/Internxt.xcodeproj/project.pbxproj
+++ b/ios/Internxt.xcodeproj/project.pbxproj
@@ -279,7 +279,6 @@
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoSystemUI/ExpoSystemUI_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ReachabilitySwift/ReachabilitySwift.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/RCTI18nStrings.bundle",
- "${PODS_CONFIGURATION_BUILD_DIR}/Rudder/Rudder.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/dr-pogodin-react-native-fs/RNFS_PrivacyInfo.bundle",
);
name = "[CP] Copy Pods Resources";
@@ -293,7 +292,6 @@
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoSystemUI_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ReachabilitySwift.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCTI18nStrings.bundle",
- "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Rudder.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNFS_PrivacyInfo.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
@@ -345,7 +343,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks;
- PRODUCT_NAME = "Internxt";
+ PRODUCT_NAME = Internxt;
SWIFT_OBJC_BRIDGING_HEADER = "Internxt/Internxt-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -376,7 +374,7 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.internxt.snacks;
- PRODUCT_NAME = "Internxt";
+ PRODUCT_NAME = Internxt;
SWIFT_OBJC_BRIDGING_HEADER = "Internxt/Internxt-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
diff --git a/ios/Internxt/Info.plist b/ios/Internxt/Info.plist
index 954cd5b5f..5df62bdf3 100644
--- a/ios/Internxt/Info.plist
+++ b/ios/Internxt/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageType
$(PRODUCT_BUNDLE_PACKAGE_TYPE)
CFBundleShortVersionString
- 1.8.2
+ 1.8.3
CFBundleSignature
????
CFBundleURLTypes
@@ -27,6 +27,7 @@
CFBundleURLSchemes
+ internxt
inxt
com.internxt.snacks
diff --git a/ios/Internxt/Supporting/Expo.plist b/ios/Internxt/Supporting/Expo.plist
index ee15d2e48..84df42871 100644
--- a/ios/Internxt/Supporting/Expo.plist
+++ b/ios/Internxt/Supporting/Expo.plist
@@ -9,7 +9,7 @@
EXUpdatesLaunchWaitMs
0
EXUpdatesRuntimeVersion
- 1.8.2
+ 1.8.3
EXUpdatesURL
https://u.expo.dev/680f4feb-6315-4a50-93ec-36dcd0b831d2
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 72cf852e0..4a0005f26 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -101,9 +101,6 @@ PODS:
- libwebp/sharpyuv (1.3.2)
- libwebp/webp (1.3.2):
- libwebp/sharpyuv
- - MetricsReporter (2.0.0):
- - RSCrashReporter (= 1.0.1)
- - RudderKit (= 1.4.0)
- RCT-Folly (2022.05.16.00):
- boost
- DoubleConversion
@@ -1172,9 +1169,6 @@ PODS:
- RCT-Folly (= 2022.05.16.00)
- React-Core
- ReactCommon/turbomodule/core
- - RNRudderSdk (1.14.1):
- - React
- - Rudder (< 2.0.0, >= 1.26.3)
- RNScreens (3.34.0):
- glog
- RCT-Folly (= 2022.05.16.00)
@@ -1184,10 +1178,6 @@ PODS:
- React-Core
- RNSVG (14.1.0):
- React-Core
- - RSCrashReporter (1.0.1)
- - Rudder (1.29.1):
- - MetricsReporter (= 2.0.0)
- - RudderKit (1.4.0)
- SDWebImage (5.11.1):
- SDWebImage/Core (= 5.11.1)
- SDWebImage/Core (5.11.1)
@@ -1299,7 +1289,6 @@ DEPENDENCIES:
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- RNPermissions (from `../node_modules/react-native-permissions`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
- - "RNRudderSdk (from `../node_modules/@rudderstack/rudder-sdk-react-native`)"
- RNScreens (from `../node_modules/react-native-screens`)
- RNShare (from `../node_modules/react-native-share`)
- RNSVG (from `../node_modules/react-native-svg`)
@@ -1310,11 +1299,7 @@ SPEC REPOS:
- fmt
- IDZSwiftCommonCrypto
- libwebp
- - MetricsReporter
- ReachabilitySwift
- - RSCrashReporter
- - Rudder
- - RudderKit
- SDWebImage
- SDWebImageWebPCoder
- SocketRocket
@@ -1513,8 +1498,6 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-permissions"
RNReanimated:
:path: "../node_modules/react-native-reanimated"
- RNRudderSdk:
- :path: "../node_modules/@rudderstack/rudder-sdk-react-native"
RNScreens:
:path: "../node_modules/react-native-screens"
RNShare:
@@ -1561,7 +1544,6 @@ SPEC CHECKSUMS:
internxt-mobile-sdk: 821a26ae1521019b968b5c2bc716ca5498e8a7d1
jail-monkey: 066e0af74e67cbf432fbb4d214b046ef6dccf910
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
- MetricsReporter: 364b98791e868b10e9d512eb50af28d8c11e5cdb
RCT-Folly: 7169b2b1c44399c76a47b5deaaba715eeeb476c0
RCTRequired: ca1d7414aba0b27efcfa2ccd37637edb1ab77d96
RCTTypeSafety: 678e344fb976ff98343ca61dc62e151f3a042292
@@ -1626,13 +1608,9 @@ SPEC CHECKSUMS:
RNGestureHandler: 15c6ef51acba34c49ff03003806cf5dd6098f383
RNPermissions: 4e3714e18afe7141d000beae3755e5b5fb2f5e05
RNReanimated: f6b02d8f5eaa2830296411d4ec3b8ef5442dd13d
- RNRudderSdk: b31488f3452592107ff3835120a13df4d00e394c
RNScreens: 29418ceffb585b8f0ebd363de304288c3dce8323
RNShare: a5dc3b9c53ddc73e155b8cd9a94c70c91913c43c
RNSVG: ba3e7232f45e34b7b47e74472386cf4e1a676d0a
- RSCrashReporter: 6b8376ac729b0289ebe0908553e5f56d8171f313
- Rudder: 731095848aee39d27ff5d0e78233aa5ad8febb0b
- RudderKit: f272f9872183946452ac94cd7bb2244a71e6ca8f
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: f32cd54efbe0f095c4d7594881e52619cfe80b17
diff --git a/jest.config.ts b/jest.config.ts
index f6c46dd2f..785365e05 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -16,7 +16,6 @@ const untranspiledModulePatterns = [
'react-native-svg',
'rn-fetch-blob',
'@internxt/rn-crypto',
- '@rudderstack',
'realm',
];
diff --git a/package.json b/package.json
index 899859b6b..3a1d162be 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "drive-mobile",
- "version": "v1.8.2",
+ "version": "v1.8.3",
"private": true,
"license": "GNU",
"scripts": {
@@ -36,14 +36,13 @@
"@internxt/lib": "^1.2.0",
"@internxt/mobile-sdk": "^0.2.41",
"@internxt/rn-crypto": "0.1.15",
- "@internxt/sdk": "1.11.0",
+ "@internxt/sdk": "=1.11.6",
"@react-native-async-storage/async-storage": "1.21.0",
"@react-navigation/bottom-tabs": "^6.2.0",
"@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.0.8",
"@realm/react": "^0.4.1",
"@reduxjs/toolkit": "^1.6.2",
- "@rudderstack/rudder-sdk-react-native": "^1.5.1",
"@shopify/flash-list": "1.6.3",
"@testing-library/react-hooks": "^8.0.1",
"@types/luxon": "^3.0.1",
diff --git a/sonar-project.properties b/sonar-project.properties
index 73cca9164..c623c69a1 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -1,2 +1,2 @@
sonar.exclusions=**/*android*.*,**/*ios*.*
-sonar.cpd.exclusions=assets/lang/strings.ts
\ No newline at end of file
+sonar.cpd.exclusions=assets/lang/strings.ts,**/*.spec.ts,**/*.spec.tsx
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 95ec406e6..b8f16dbb7 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,16 +2,8 @@ import Portal from '@burstware/react-native-portal';
import * as Linking from 'expo-linking';
import * as NavigationBar from 'expo-navigation-bar';
import { useEffect, useState } from 'react';
-import {
- Appearance,
- AppStateStatus,
- KeyboardAvoidingView,
- NativeEventSubscription,
- Platform,
- Text,
- View,
-} from 'react-native';
-import { CaptureProtection, CaptureProtectionProvider } from 'react-native-capture-protection';
+import { AppStateStatus, KeyboardAvoidingView, NativeEventSubscription, Platform, Text, View } from 'react-native';
+import { CaptureProtectionProvider } from 'react-native-capture-protection';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useTailwind } from 'tailwind-rn';
@@ -23,12 +15,13 @@ import LanguageModal from './components/modals/LanguageModal';
import LinkCopiedModal from './components/modals/LinkCopiedModal';
import { DriveContextProvider } from './contexts/Drive';
+import { ThemeProvider, useTheme } from './contexts/Theme';
import { getRemoteUpdateIfAvailable, useLoadFonts } from './helpers';
import useGetColor from './hooks/useColor';
+import { useScreenProtection } from './hooks/useScreenProtection';
import { useSecurity } from './hooks/useSecurity';
import Navigation from './navigation';
import { LockScreen } from './screens/common/LockScreen';
-import analyticsService from './services/AnalyticsService';
import appService from './services/AppService';
import asyncStorageService from './services/AsyncStorageService';
import authService from './services/AuthService';
@@ -44,43 +37,18 @@ import { uiActions } from './store/slices/ui';
let listener: NativeEventSubscription | null = null;
-export default function App(): JSX.Element {
+function AppContent(): JSX.Element {
const dispatch = useAppDispatch();
const tailwind = useTailwind();
const getColor = useGetColor();
+ const { theme } = useTheme();
const { isReady: fontsAreReady } = useLoadFonts();
const { user } = useAppSelector((state) => state.auth);
const { screenLocked, lastScreenLock, initialScreenLocked, screenLockEnabled } = useAppSelector((state) => state.app);
- const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>('light');
const { performPeriodicSecurityCheck } = useSecurity();
- useEffect(() => {
- const initializeTheme = async () => {
- const savedTheme = await asyncStorageService.getThemePreference();
- const themeToApply = savedTheme || Appearance.getColorScheme() || 'light';
-
- setCurrentTheme(themeToApply as 'light' | 'dark');
- Appearance.setColorScheme(themeToApply);
- };
-
- initializeTheme();
- const subscription = Appearance.addChangeListener(({ colorScheme }) => {
- asyncStorageService.getThemePreference().then((savedTheme) => {
- if (!savedTheme && colorScheme) {
- setCurrentTheme(colorScheme);
- }
- });
- });
-
- return () => {
- subscription?.remove();
- if (!screenLockEnabled) {
- dispatch(appActions.setInitialScreenLocked(false));
- dispatch(appActions.setScreenLocked(false));
- }
- };
- }, []);
+ useScreenProtection();
const [isAppInitialized, setIsAppInitialized] = useState(false);
const [loadError, setLoadError] = useState('');
@@ -91,7 +59,6 @@ export default function App(): JSX.Element {
isEditNameModalOpen,
isChangeProfilePictureModalOpen,
isLanguageModalOpen,
- isPlansModalOpen,
} = useAppSelector((state) => state.ui);
const silentSignIn = async () => {
@@ -105,7 +72,6 @@ export default function App(): JSX.Element {
const onEditNameModalClosed = () => dispatch(uiActions.setIsEditNameModalOpen(false));
const onChangeProfilePictureModalClosed = () => dispatch(uiActions.setIsChangeProfilePictureModalOpen(false));
const onLanguageModalClosed = () => dispatch(uiActions.setIsLanguageModalOpen(false));
- const onPlansModalClosed = () => dispatch(uiActions.setIsPlansModalOpen(false));
const handleAppStateChange = (state: AppStateStatus) => {
if (state === 'active') {
@@ -193,7 +159,7 @@ export default function App(): JSX.Element {
await fileSystemService.prepareFileSystem();
// 4. Initialize all the services we need at start time
- const initializeOperations = [authService.init(), analyticsService.setup(), appService.logAppInfo()];
+ const initializeOperations = [authService.init(), appService.logAppInfo()];
await Promise.all(initializeOperations);
// 5. Silent SignIn only if token is still valid
@@ -207,17 +173,6 @@ export default function App(): JSX.Element {
};
useEffect(() => {
- CaptureProtection.prevent();
-
- const initializeTheme = async () => {
- const savedTheme = await asyncStorageService.getThemePreference();
- if (savedTheme) {
- Appearance.setColorScheme(savedTheme);
- }
- };
-
- initializeTheme();
-
return () => {
if (!screenLockEnabled) {
dispatch(appActions.setInitialScreenLocked(false));
@@ -246,7 +201,7 @@ export default function App(): JSX.Element {
if (Platform.OS === 'android') {
try {
const backgroundColor = getColor('bg-surface');
- const isDark = currentTheme === 'dark';
+ const isDark = theme === 'dark';
await NavigationBar.setBackgroundColorAsync(backgroundColor);
await NavigationBar.setButtonStyleAsync(isDark ? 'light' : 'dark');
@@ -257,7 +212,7 @@ export default function App(): JSX.Element {
};
configureNavigationBar();
- }, [getColor, currentTheme]);
+ }, [getColor, theme]);
useEffect(() => {
const globalListener = appService.onAppStateChange(handleGlobalAppStateChange);
@@ -305,3 +260,11 @@ export default function App(): JSX.Element {
);
}
+
+export default function App(): JSX.Element {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/DriveNavigableItem/index.tsx b/src/components/DriveNavigableItem/index.tsx
index 503299b6a..f5b836bb0 100644
--- a/src/components/DriveNavigableItem/index.tsx
+++ b/src/components/DriveNavigableItem/index.tsx
@@ -1,10 +1,10 @@
-import { items } from '@internxt/lib';
import { CaretRight } from 'phosphor-react-native';
import prettysize from 'prettysize';
import React from 'react';
import { TouchableHighlight, View } from 'react-native';
import { useTailwind } from 'tailwind-rn';
import { FolderIcon, getFileTypeIcon } from '../../helpers';
+import { getDisplayName } from '../../helpers/itemNames';
import useGetColor from '../../hooks/useColor';
import globalStyle from '../../styles/global';
import { DriveNavigableItemProps } from '../../types/drive';
@@ -54,7 +54,7 @@ const DriveNavigableItem: React.FC = ({ isLoading, disa
numberOfLines={1}
ellipsizeMode={'middle'}
>
- {items.getItemDisplayName(props.data)}
+ {getDisplayName(props.data)}
diff --git a/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx b/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
index 0a8829950..ec9d39021 100644
--- a/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
+++ b/src/components/drive/lists/items/DriveGridModeItem/DriveGridModeItem.tsx
@@ -42,6 +42,9 @@ function DriveGridModeItemComp(props: DriveItemProps): JSX.Element {
useEffect(() => {
if (props.data.thumbnails && props.data.thumbnails.length && !downloadedThumbnail) {
InteractionManager.runAfterInteractions(() => {
+ // TODO: NEED TO UPDATE SDK TYPES
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
driveFileService.getThumbnail(props.data.thumbnails[0]).then((downloadedThumbnail) => {
setDownloadedThumbnail(downloadedThumbnail);
});
diff --git a/src/components/modals/AddModal/index.tsx b/src/components/modals/AddModal/index.tsx
index 1cb55f6d1..b152703b2 100644
--- a/src/components/modals/AddModal/index.tsx
+++ b/src/components/modals/AddModal/index.tsx
@@ -16,6 +16,11 @@ 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 drive from '@internxt-mobile/services/drive';
+import {
+ generateFileName,
+ isTemporaryFileName,
+ parseExifDate,
+} from '@internxt-mobile/services/drive/file/utils/exifHelpers';
import errorService from '@internxt-mobile/services/ErrorService';
import { DriveFileData, EncryptionVersion, FileEntryByUuid, Thumbnail } from '@internxt/sdk/dist/drive/storage/types';
import { SaveFormat } from 'expo-image-manipulator';
@@ -46,7 +51,7 @@ import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { driveActions, driveThunks } from '../../../store/slices/drive';
import { uiActions } from '../../../store/slices/ui';
import { NotificationType, ProgressCallback } from '../../../types';
-import { DriveEventKey, UPLOAD_FILE_SIZE_LIMIT, UploadingFile } from '../../../types/drive';
+import { DocumentPickerFile, DriveEventKey, UPLOAD_FILE_SIZE_LIMIT, UploadingFile } from '../../../types/drive';
import AppText from '../../AppText';
import BottomModal from '../BottomModal';
import CreateFolderModal from '../CreateFolderModal';
@@ -127,6 +132,8 @@ function AddModal(): JSX.Element {
fileExtension,
fileToUpload.parentUuid,
progressCallback,
+ fileToUpload.modificationTime,
+ fileToUpload.creationTime,
);
dispatch(driveActions.uploadingFileEnd(fileToUpload.id));
@@ -157,6 +164,8 @@ function AddModal(): JSX.Element {
fileExtension: string,
currentFolderId: string,
progressCallback: ProgressCallback,
+ modificationTime?: string,
+ creationTime?: string,
) {
const { bucket, bridgeUser, mnemonic, userId } = await asyncStorage.getUser();
logger.info('Stating file...');
@@ -184,22 +193,29 @@ function AddModal(): JSX.Element {
notifyProgress: progressCallback,
},
);
-
logger.info('File uploaded with fileId: ', fileId);
logger.info('File uploaded with name: ', fileName);
+ logger.info('File uploaded with modificationTime: ', modificationTime);
+ logger.info('File uploaded with creationTime: ', creationTime);
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 = {
- id: fileId,
+ fileId: fileId,
type: fileExtension,
size: fileSize,
- name: plainName,
- plain_name: plainName,
+ plainName: plainName,
bucket,
- folder_id: folderId,
- encrypt_version: EncryptionVersion.Aes03,
+ folderUuid: folderId,
+ encryptVersion: EncryptionVersion.Aes03,
+ modificationTime: modificationTimeISO,
+ creationTime: creationTimeISO,
};
let uploadedThumbnail: Thumbnail | null = null;
@@ -319,14 +335,14 @@ function AddModal(): JSX.Element {
dispatch(driveActions.setUri(undefined));
}
- function processFilesFromPicker(documents: DocumentPickerResponse[]): Promise {
+ function processFilesFromPicker(documents: DocumentPickerFile[]): Promise {
documents.forEach((doc) => (doc.uri = doc.fileCopyUri));
dispatch(uiActions.setShowUploadFileModal(false));
return uploadDocuments(documents);
}
- async function uploadDocuments(documents: DocumentPickerResponse[]) {
+ async function uploadDocuments(documents: DocumentPickerFile[]) {
if (!focusedFolder) {
throw new Error('No current folder found');
}
@@ -334,7 +350,6 @@ function AddModal(): JSX.Element {
const { filesToUpload, filesExcluded } = validateAndFilterFiles(documents);
showFileSizeAlert(filesExcluded);
const filesToProcess = await handleDuplicateFiles(filesToUpload, focusedFolder.uuid);
-
if (filesToProcess.length === 0) {
dispatch(uiActions.setShowUploadFileModal(false));
return;
@@ -431,7 +446,6 @@ function AddModal(): JSX.Element {
try {
const assetInfo = await MediaLibrary.getAssetInfoAsync(asset.assetId || asset.uri);
const cleanUri = assetInfo.mediaType === 'video' ? asset.uri : assetInfo.localUri || asset.uri;
- // asset info has the correct format (heic issue)
const originalFileName = assetInfo.filename || asset.fileName;
let fileSize = asset.fileSize;
@@ -455,10 +469,11 @@ function AddModal(): JSX.Element {
} catch (error) {
logger.error('Error obtaining original asset info:', error);
const cleanUri = asset.uri;
- const formatInfo = detectImageFormat(asset);
+ const fallbackName = generateFileName(cleanUri);
+
documents.push({
fileCopyUri: cleanUri,
- name: asset.fileName ?? `media_${Date.now()}.${formatInfo.extension ?? 'jpg'}`,
+ name: fallbackName,
size: asset.fileSize ?? 0,
type: asset.type ?? '',
uri: cleanUri,
@@ -496,35 +511,99 @@ function AddModal(): JSX.Element {
}
}
} else {
- DocumentPicker.pickMultiple({
- type: [DocumentPicker.types.images],
- copyTo: 'cachesDirectory',
- })
- .then(processFilesFromPicker)
- .then(async () => {
- dispatch(driveThunks.loadUsageThunk());
+ const { status } = await MediaLibrary.requestPermissionsAsync();
- if (focusedFolder) {
- await SLEEP_BECAUSE_MAYBE_BACKEND_IS_NOT_RETURNING_FRESHLY_MODIFIED_OR_CREATED_ITEMS_YET(1000);
- driveCtx.loadFolderContent(focusedFolder.uuid, {
- pullFrom: ['network'],
- resetPagination: true,
- });
- }
- })
- .catch((err) => {
- if (err.message === 'User canceled document picker') {
- return;
- }
- logger.error('Error on handleUploadFromCameraRoll function:', JSON.stringify(err));
- notificationsService.show({
- type: NotificationType.Error,
- text1: strings.formatString(strings.errors.uploadFile, err.message) as string,
+ if (status === 'granted') {
+ try {
+ const result = await launchImageLibraryAsync({
+ mediaTypes: MediaTypeOptions.All,
+ allowsMultipleSelection: true,
+ selectionLimit: MAX_FILES_BULK_UPLOAD,
+ allowsEditing: false,
+ preferredAssetRepresentationMode: UIImagePickerPreferredAssetRepresentationMode.Current,
+ exif: true,
+ base64: false,
});
- })
- .finally(() => {
+
+ if (result.canceled || !result.assets?.length) return;
+
+ const documents: DocumentPickerFile[] = [];
+
+ for (const asset of result.assets) {
+ try {
+ const cleanUri = asset.uri;
+ let originalFileName = asset.fileName;
+ const exif = asset.exif || null;
+
+ const creationTime = parseExifDate(exif?.DateTimeOriginal);
+ const modificationTime = parseExifDate(exif?.DateTime);
+ if (isTemporaryFileName(originalFileName)) {
+ originalFileName = generateFileName(cleanUri, creationTime, modificationTime);
+ }
+
+ let fileSize = asset.fileSize ?? 0;
+ if (!fileSize) {
+ try {
+ const fileInfo = await FileSystem.getInfoAsync(cleanUri);
+ fileSize = fileInfo.exists ? fileInfo.size || 0 : 0;
+ } catch (error) {
+ logger.warn('The file size could not be obtained:', error);
+ fileSize = 0;
+ }
+ }
+
+ documents.push({
+ fileCopyUri: cleanUri,
+ name: decodeURIComponent(originalFileName ?? ''),
+ size: fileSize,
+ type: drive.file.getExtensionFromUri(cleanUri)?.toLowerCase() ?? '',
+ uri: cleanUri,
+ creationTime,
+ modificationTime,
+ });
+ } catch (error) {
+ logger.error('Error obtaining original asset info:', error);
+ const cleanUri = asset.uri;
+ const fallbackName = generateFileName(cleanUri);
+
+ documents.push({
+ fileCopyUri: cleanUri,
+ name: fallbackName,
+ size: asset.fileSize ?? 0,
+ type: asset.type ?? '',
+ uri: cleanUri,
+ });
+ }
+ }
+
dispatch(uiActions.setShowUploadFileModal(false));
- });
+
+ uploadDocuments(documents)
+ .then(async () => {
+ dispatch(driveThunks.loadUsageThunk());
+
+ if (focusedFolder) {
+ await SLEEP_BECAUSE_MAYBE_BACKEND_IS_NOT_RETURNING_FRESHLY_MODIFIED_OR_CREATED_ITEMS_YET(500);
+ driveCtx.loadFolderContent(focusedFolder.uuid, {
+ pullFrom: ['network'],
+ resetPagination: true,
+ });
+ }
+ })
+ .catch((err) => {
+ logger.error('Error on handleUploadFromCameraRoll (Android):', JSON.stringify(err));
+ notificationsService.show({
+ type: NotificationType.Error,
+ text1: strings.formatString(strings.errors.uploadFile, err.message) as string,
+ });
+ })
+ .finally(() => {
+ dispatch(uiActions.setShowUploadFileModal(false));
+ });
+ } catch (error) {
+ logger.error('Error accessing media library (Android):', error);
+ }
+ }
}
}
diff --git a/src/components/modals/DriveItemInfoModal/index.tsx b/src/components/modals/DriveItemInfoModal/index.tsx
index efffb703f..65ae7744a 100644
--- a/src/components/modals/DriveItemInfoModal/index.tsx
+++ b/src/components/modals/DriveItemInfoModal/index.tsx
@@ -101,6 +101,7 @@ function DriveItemInfoModal(): JSX.Element {
{
dbItemId: dbItem?.id || item.id,
id: isFolder ? item.id.toString() : (item.fileId as string),
+ uuid: item?.uuid,
type: isFolder ? 'folder' : 'file',
},
],
diff --git a/src/contexts/Drive/Drive.context.tsx b/src/contexts/Drive/Drive.context.tsx
index a7e7a068e..ffc030b8f 100644
--- a/src/contexts/Drive/Drive.context.tsx
+++ b/src/contexts/Drive/Drive.context.tsx
@@ -8,6 +8,7 @@ import errorService from '@internxt-mobile/services/ErrorService';
import { AppStateStatus, NativeEventSubscription } from 'react-native';
import { driveFolderService } from '@internxt-mobile/services/drive/folder';
+import { Thumbnail } from '@internxt/sdk/dist/drive/storage/types';
export type DriveFoldersTreeNode = {
name: string;
@@ -32,7 +33,11 @@ export interface DriveContextType {
toggleViewMode: () => void;
loadFolderContent: (folderUuid: string, options?: LoadFolderContentOptions) => Promise;
focusedFolder: DriveFoldersTreeNode | null;
- updateItemInTree: (folderId: string, itemId: number, updates: { name?: string; plainName?: string }) => void;
+ updateItemInTree: (
+ folderId: string,
+ itemId: number,
+ updates: { name?: string; plainName?: string; thumbnails?: Thumbnail[] },
+ ) => void;
removeItemFromTree: (folderId: string, itemId: number) => void;
addItemToTree: (folderId: string, item: DriveItemData, isFolder: boolean) => void;
}
@@ -324,7 +329,11 @@ export const DriveContextProvider: React.FC = ({ chil
});
};
- const updateItemInTree = (folderId: string, itemId: number, updates: { name?: string; plainName?: string }) => {
+ const updateItemInTree = (
+ folderId: string,
+ itemId: number,
+ updates: { name?: string; plainName?: string; thumbnails?: Thumbnail[] },
+ ) => {
setDriveFoldersTree((prevTree) => {
const folder = prevTree[folderId];
if (!folder) return prevTree;
diff --git a/src/contexts/Theme/Theme.context.spec.tsx b/src/contexts/Theme/Theme.context.spec.tsx
new file mode 100644
index 000000000..3272a8aea
--- /dev/null
+++ b/src/contexts/Theme/Theme.context.spec.tsx
@@ -0,0 +1,410 @@
+jest.mock('@internxt-mobile/services/AsyncStorageService');
+jest.mock('@internxt-mobile/services/common');
+
+import asyncStorageService from '@internxt-mobile/services/AsyncStorageService';
+import { logger } from '@internxt-mobile/services/common';
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+import React from 'react';
+import { Appearance, ColorSchemeName } from 'react-native';
+import { ThemeProvider, useTheme } from './Theme.context';
+
+type AppearanceListener = (preferences: { colorScheme: ColorSchemeName }) => void;
+
+describe('Theme.context', () => {
+ const mockGetThemePreference = asyncStorageService.getThemePreference as jest.Mock;
+ const mockSaveThemePreference = asyncStorageService.saveThemePreference as jest.Mock;
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => {children};
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('light');
+ jest.spyOn(Appearance, 'setColorScheme').mockImplementation(() => undefined);
+ jest.spyOn(Appearance, 'addChangeListener').mockReturnValue({ remove: jest.fn() });
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ });
+
+ describe('useTheme hook', () => {
+ it('should throw error when used outside ThemeProvider', () => {
+ // Suppress console.error for this test since we expect an error
+ const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+
+ expect(() => renderHook(() => useTheme())).toThrow('useTheme must be used within a ThemeProvider');
+
+ consoleSpy.mockRestore();
+ });
+ });
+
+ describe('initialization', () => {
+ it('should initialize with saved theme preference', async () => {
+ mockGetThemePreference.mockResolvedValue('dark');
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ expect(result.current.isInitialized).toBe(false);
+
+ // Fast-forward timers to complete initialization
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ expect(result.current.theme).toBe('dark');
+ expect(Appearance.setColorScheme).toHaveBeenCalledWith('dark');
+ expect(mockGetThemePreference).toHaveBeenCalledTimes(1);
+ });
+
+ it('should initialize with system theme when no saved preference', async () => {
+ mockGetThemePreference.mockResolvedValue(null);
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue('dark');
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ expect(result.current.theme).toBe('dark');
+ expect(Appearance.setColorScheme).toHaveBeenCalledWith('dark');
+ });
+
+ it('should default to light theme when no preference and no system theme', async () => {
+ mockGetThemePreference.mockResolvedValue(null);
+ jest.spyOn(Appearance, 'getColorScheme').mockReturnValue(null);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ expect(result.current.theme).toBe('light');
+ expect(Appearance.setColorScheme).toHaveBeenCalledWith('light');
+ });
+
+ it('should handle initialization error gracefully and use light fallback theme', async () => {
+ const error = new Error('Storage error');
+ mockGetThemePreference.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+ expect(result.current.theme).toBe('light');
+ expect(result.current.isInitialized).toBe(true);
+ expect(logger.error).toHaveBeenCalledWith('Error loading theme preference:', error);
+ });
+
+ it('should setup theme change listener during initialization', async () => {
+ mockGetThemePreference.mockResolvedValue('light');
+
+ renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(Appearance.addChangeListener).toHaveBeenCalledTimes(1);
+ expect(Appearance.addChangeListener).toHaveBeenCalledWith(expect.any(Function));
+ });
+ });
+
+ describe('setTheme', () => {
+ it('should update theme and save preference', async () => {
+ mockGetThemePreference.mockResolvedValue('light');
+ mockSaveThemePreference.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ await act(async () => {
+ await result.current.setTheme('dark');
+ });
+
+ expect(result.current.theme).toBe('dark');
+ expect(mockSaveThemePreference).toHaveBeenCalledWith('dark');
+ expect(Appearance.setColorScheme).toHaveBeenCalledWith('dark');
+ });
+
+ it('should handle errors when saving theme preference', async () => {
+ const error = new Error('Save error');
+ mockGetThemePreference.mockResolvedValue('light');
+ mockSaveThemePreference.mockRejectedValue(error);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ await act(async () => {
+ await result.current.setTheme('dark');
+ });
+
+ expect(logger.error).toHaveBeenCalledWith('Error saving theme preference:', error);
+ // Theme should still be updated in state
+ expect(result.current.theme).toBe('dark');
+ });
+
+ it('should switch between themes correctly', async () => {
+ mockGetThemePreference.mockResolvedValue('light');
+ mockSaveThemePreference.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ // Change to dark
+ await act(async () => {
+ await result.current.setTheme('dark');
+ });
+
+ expect(result.current.theme).toBe('dark');
+
+ // Change back to light
+ await act(async () => {
+ await result.current.setTheme('light');
+ });
+
+ expect(result.current.theme).toBe('light');
+ expect(mockSaveThemePreference).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('system theme changes', () => {
+ it('should apply system theme changes when no saved preference exists', async () => {
+ let changeListener: AppearanceListener | null = null;
+
+ mockGetThemePreference.mockResolvedValue(null);
+ jest.spyOn(Appearance, 'addChangeListener').mockImplementation((callback) => {
+ changeListener = callback;
+ return { remove: jest.fn() };
+ });
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ // Simulate system theme change
+ await act(async () => {
+ changeListener?.({ colorScheme: 'dark' });
+ await Promise.resolve();
+ });
+
+ expect(result.current.theme).toBe('dark');
+ expect(Appearance.setColorScheme).toHaveBeenCalledWith('dark');
+ });
+
+ it('should ignore system theme changes when user has saved preference', async () => {
+ let changeListener: AppearanceListener | null = null;
+
+ mockGetThemePreference.mockResolvedValue('light');
+ jest.spyOn(Appearance, 'addChangeListener').mockImplementation((callback) => {
+ changeListener = callback;
+ return { remove: jest.fn() };
+ });
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ const setColorSchemeCallsBefore = (Appearance.setColorScheme as jest.Mock).mock.calls.length;
+
+ // Simulate system theme change
+ await act(async () => {
+ changeListener?.({ colorScheme: 'dark' });
+ await Promise.resolve();
+ });
+
+ // Theme should NOT change
+ expect(result.current.theme).toBe('light');
+ // SetColorScheme should not be called again
+ expect((Appearance.setColorScheme as jest.Mock).mock.calls.length).toBe(setColorSchemeCallsBefore);
+ });
+
+ it('should ignore system theme changes during initialization', async () => {
+ let changeListener: AppearanceListener | null = null;
+
+ mockGetThemePreference.mockResolvedValue('light');
+ jest.spyOn(Appearance, 'addChangeListener').mockImplementation((callback) => {
+ changeListener = callback;
+ return { remove: jest.fn() };
+ });
+
+ renderHook(() => useTheme(), { wrapper });
+
+ // Trigger change BEFORE initialization completes
+ await act(async () => {
+ await Promise.resolve();
+ changeListener?.({ colorScheme: 'dark' });
+ });
+
+ expect(logger.info).toHaveBeenCalledWith('Ignoring theme change event during initialization');
+ });
+
+ it('should ignore system theme changes triggered by manual theme change', async () => {
+ let changeListener: AppearanceListener | null = null;
+
+ mockGetThemePreference.mockResolvedValue(null);
+ mockSaveThemePreference.mockResolvedValue(undefined);
+ jest.spyOn(Appearance, 'addChangeListener').mockImplementation((callback) => {
+ changeListener = callback;
+ return { remove: jest.fn() };
+ });
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ // Manually change theme
+ await act(async () => {
+ await result.current.setTheme('dark');
+ // Simulate listener being triggered by our own setColorScheme
+ changeListener?.({ colorScheme: 'dark' });
+ await Promise.resolve();
+ });
+
+ expect(logger.info).toHaveBeenCalledWith('Ignoring theme change event triggered by manual theme change');
+ });
+ });
+
+ describe('cleanup', () => {
+ it('should remove listener on unmount', async () => {
+ const removeMock = jest.fn();
+ mockGetThemePreference.mockResolvedValue('light');
+ jest.spyOn(Appearance, 'addChangeListener').mockReturnValue({ remove: removeMock });
+
+ const { unmount } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ unmount();
+
+ expect(removeMock).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('context value memoization', () => {
+ it('should memoize context value when dependencies do not change', async () => {
+ mockGetThemePreference.mockResolvedValue('light');
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ // Verify that the context value has the expected structure
+ expect(result.current).toEqual({
+ theme: 'light',
+ setTheme: expect.any(Function),
+ isInitialized: true,
+ });
+
+ // The values should remain stable across reads
+ expect(result.current.theme).toBe('light');
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ it('should recreate context value when theme changes', async () => {
+ mockGetThemePreference.mockResolvedValue('light');
+ mockSaveThemePreference.mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useTheme(), { wrapper });
+
+ await act(async () => {
+ await Promise.resolve();
+ jest.advanceTimersByTime(100);
+ });
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ const firstValue = result.current;
+
+ // Change theme
+ await act(async () => {
+ await result.current.setTheme('dark');
+ });
+
+ // Should be a different object reference
+ expect(result.current).not.toBe(firstValue);
+ expect(result.current.theme).toBe('dark');
+ });
+ });
+});
diff --git a/src/contexts/Theme/Theme.context.tsx b/src/contexts/Theme/Theme.context.tsx
new file mode 100644
index 000000000..e280029d2
--- /dev/null
+++ b/src/contexts/Theme/Theme.context.tsx
@@ -0,0 +1,166 @@
+import asyncStorageService from '@internxt-mobile/services/AsyncStorageService';
+import { logger } from '@internxt-mobile/services/common';
+import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
+import { Appearance, NativeEventSubscription } from 'react-native';
+
+export type ThemeMode = 'light' | 'dark';
+
+interface ThemeContextType {
+ theme: ThemeMode;
+ setTheme: (theme: ThemeMode) => Promise;
+ isInitialized: boolean;
+}
+
+const ThemeContext = createContext(undefined);
+
+interface ThemeProviderProps {
+ children: React.ReactNode;
+}
+
+/**
+ * Loads the saved theme preference from storage or falls back to system theme
+ */
+const loadThemePreference = async (): Promise => {
+ try {
+ const savedTheme = await asyncStorageService.getThemePreference();
+ logger.info(`Saved theme from storage: ${savedTheme}`);
+
+ if (savedTheme) {
+ return savedTheme;
+ }
+
+ const systemTheme = Appearance.getColorScheme() as ThemeMode;
+ logger.info(`No saved theme, using system theme: ${systemTheme}`);
+ return systemTheme || 'light';
+ } catch (error) {
+ logger.error('Error loading theme preference:', error);
+ return 'light';
+ }
+};
+
+/**
+ * Applies the theme to both React state and native appearance
+ */
+const applyTheme = (theme: ThemeMode, setThemeState: (theme: ThemeMode) => void): void => {
+ logger.info(`Applying theme: ${theme}`);
+ setThemeState(theme);
+ Appearance.setColorScheme(theme);
+};
+
+/**
+ * Handles system theme changes, only applying if no user preference is saved
+ */
+const handleSystemThemeChange = async (
+ colorScheme: string | null | undefined,
+ isInitializingRef: React.MutableRefObject,
+ isSettingThemeRef: React.MutableRefObject,
+ setThemeState: (theme: ThemeMode) => void,
+): Promise => {
+ if (isInitializingRef.current) {
+ logger.info('Ignoring theme change event during initialization');
+ return;
+ }
+
+ if (isSettingThemeRef.current) {
+ logger.info('Ignoring theme change event triggered by manual theme change');
+ return;
+ }
+
+ logger.info(`System theme changed to: ${colorScheme}`);
+
+ const currentSavedTheme = await asyncStorageService.getThemePreference();
+
+ if (!currentSavedTheme && colorScheme) {
+ logger.info(`Applying system theme: ${colorScheme} (no saved preference)`);
+ applyTheme(colorScheme as ThemeMode, setThemeState);
+ } else if (currentSavedTheme) {
+ logger.info(`Ignoring system theme change (user preference: ${currentSavedTheme})`);
+ }
+};
+
+/**
+ * Sets up the listener for system theme changes
+ */
+const setupThemeListener = (
+ isInitializingRef: React.MutableRefObject,
+ isSettingThemeRef: React.MutableRefObject,
+ setThemeState: (theme: ThemeMode) => void,
+): NativeEventSubscription => {
+ return Appearance.addChangeListener(async ({ colorScheme }) => {
+ await handleSystemThemeChange(colorScheme, isInitializingRef, isSettingThemeRef, setThemeState);
+ });
+};
+
+export const ThemeProvider: React.FC = ({ children }) => {
+ const [themeState, setThemeState] = useState('light');
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ const isInitializingRef = useRef(true);
+ const isSettingThemeRef = useRef(false);
+
+ useEffect(() => {
+ let subscription: NativeEventSubscription | null = null;
+
+ const initializeTheme = async () => {
+ try {
+ subscription = setupThemeListener(isInitializingRef, isSettingThemeRef, setThemeState);
+
+ const themeToApply = await loadThemePreference();
+ applyTheme(themeToApply, setThemeState);
+
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ isInitializingRef.current = false;
+ setIsInitialized(true);
+ logger.info('Theme initialization complete');
+ } catch (error) {
+ logger.error('Error initializing theme:', error);
+ const fallbackTheme = (Appearance.getColorScheme() as ThemeMode) || 'light';
+ applyTheme(fallbackTheme, setThemeState);
+ isInitializingRef.current = false;
+ setIsInitialized(true);
+ }
+ };
+
+ initializeTheme();
+
+ return () => {
+ subscription?.remove();
+ };
+ }, []);
+
+ const setTheme = async (newTheme: ThemeMode) => {
+ try {
+ logger.info(`Setting theme to: ${newTheme}`);
+ isSettingThemeRef.current = true;
+
+ applyTheme(newTheme, setThemeState);
+ await asyncStorageService.saveThemePreference(newTheme);
+
+ setTimeout(() => {
+ isSettingThemeRef.current = false;
+ }, 100);
+ } catch (error) {
+ logger.error('Error saving theme preference:', error);
+ isSettingThemeRef.current = false;
+ }
+ };
+
+ const contextValue = useMemo(
+ () => ({
+ theme: themeState,
+ setTheme,
+ isInitialized,
+ }),
+ [themeState, isInitialized, setTheme],
+ );
+
+ return {children};
+};
+
+export const useTheme = (): ThemeContextType => {
+ const context = useContext(ThemeContext);
+ if (context === undefined) {
+ throw new Error('useTheme must be used within a ThemeProvider');
+ }
+ return context;
+};
diff --git a/src/contexts/Theme/index.ts b/src/contexts/Theme/index.ts
new file mode 100644
index 000000000..f51528c01
--- /dev/null
+++ b/src/contexts/Theme/index.ts
@@ -0,0 +1,2 @@
+export { ThemeProvider, useTheme } from './Theme.context';
+export type { ThemeMode } from './Theme.context';
diff --git a/src/helpers/itemNames.spec.ts b/src/helpers/itemNames.spec.ts
new file mode 100644
index 000000000..10ced358a
--- /dev/null
+++ b/src/helpers/itemNames.spec.ts
@@ -0,0 +1,335 @@
+import { DriveItemDataProps } from '../types/drive';
+import { getDisplayName } from './itemNames';
+
+describe('getDisplayName', () => {
+ describe('Folders', () => {
+ it('should return the folder name as-is', () => {
+ const folder = {
+ id: 1,
+ name: 'Documents',
+ isFolder: true,
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(folder)).toBe('Documents');
+ });
+
+ it('should handle folder names with special characters', () => {
+ const folder: DriveItemDataProps = {
+ id: 1,
+ name: 'My Folder (2024)',
+ isFolder: true,
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(folder)).toBe('My Folder (2024)');
+ }) as unknown as DriveItemDataProps;
+
+ it('should handle folder names with dots', () => {
+ const folder: DriveItemDataProps = {
+ id: 1,
+ name: 'backup.2024',
+ isFolder: true,
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(folder)).toBe('backup.2024');
+ });
+ });
+
+ describe('Files without extension in name', () => {
+ it('should append the extension to the file name', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'report',
+ type: 'pdf',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('report.pdf');
+ });
+
+ it('should handle different file extensions', () => {
+ const cases = [
+ { name: 'image', type: 'jpg', expected: 'image.jpg' },
+ { name: 'document', type: 'docx', expected: 'document.docx' },
+ { name: 'video', type: 'mp4', expected: 'video.mp4' },
+ { name: 'data', type: 'json', expected: 'data.json' },
+ ];
+
+ cases.forEach(({ name, type, expected }) => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name,
+ type,
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe(expected);
+ });
+ });
+ });
+
+ describe('Files with extension already in name', () => {
+ it('should always append the type extension even if present in name', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'report.pdf',
+ type: 'pdf',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('report.pdf.pdf');
+ });
+
+ it('should append extension regardless of case matching', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'Image.JPG',
+ type: 'jpg',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('Image.JPG.jpg');
+ });
+
+ it('should always append type to files with multiple dots in name', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'backup.2024.01.15.tar',
+ type: 'tar',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('backup.2024.01.15.tar.tar');
+ });
+
+ it('should append type even when name ends with same extension', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'document.PDF',
+ type: 'pdf',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('document.PDF.pdf');
+ });
+ });
+
+ describe('Files without type', () => {
+ it('should return the name as-is when type is undefined', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'unknown',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('unknown');
+ });
+
+ it('should return the name as-is when type is null', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'noextension',
+ type: undefined,
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('noextension');
+ });
+
+ it('should preserve existing extension in name when type is undefined', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'file.txt',
+ type: undefined,
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('file.txt');
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle empty type string', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'file',
+ type: '',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('file');
+ });
+
+ it('should handle type with leading/trailing whitespace', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'document',
+ type: ' pdf ',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('document. pdf ');
+ });
+
+ it('should handle type with only whitespace', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'document',
+ type: ' ',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('document');
+ });
+
+ it('should handle files with dots but different extension', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'archive.tar',
+ type: 'gz',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('archive.tar.gz');
+ });
+
+ it('should handle numeric extensions', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'file',
+ type: '001',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('file.001');
+ });
+
+ it('should handle special characters in extension', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'document',
+ type: 'pdf~',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('document.pdf~');
+ });
+
+ it('should handle very long extensions', () => {
+ const longExtension = 'verylongextensionname';
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'file',
+ type: longExtension,
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe(`file.${longExtension}`);
+ });
+ });
+
+ describe('Real-world scenarios', () => {
+ it('should handle common document files', () => {
+ const scenarios = [
+ { name: 'Meeting Notes', type: 'docx', expected: 'Meeting Notes.docx' },
+ { name: 'Budget 2024', type: 'xlsx', expected: 'Budget 2024.xlsx' },
+ { name: 'Presentation', type: 'pptx', expected: 'Presentation.pptx' },
+ ];
+
+ scenarios.forEach(({ name, type, expected }) => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name,
+ type,
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe(expected);
+ });
+ });
+
+ it('should handle compressed archives with double extensions', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'backup.tar',
+ type: 'gz',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('backup.tar.gz');
+ });
+
+ it('should handle versioned files', () => {
+ const file: DriveItemDataProps = {
+ id: 1,
+ name: 'document.v2',
+ type: 'pdf',
+ isFolder: false,
+ fileId: 'file-1',
+ updatedAt: '2025-01-01',
+ createdAt: '2025-01-01',
+ } as unknown as DriveItemDataProps;
+
+ expect(getDisplayName(file)).toBe('document.v2.pdf');
+ });
+ });
+});
diff --git a/src/helpers/itemNames.ts b/src/helpers/itemNames.ts
new file mode 100644
index 000000000..26929d1f6
--- /dev/null
+++ b/src/helpers/itemNames.ts
@@ -0,0 +1,31 @@
+import { DriveItemDataProps } from '../types/drive';
+
+/**
+ * Generates a display name for a drive item (file or folder).
+ * For folders, returns the name as-is.
+ * For files, always appends the type extension if present.
+ *
+ * @param {DriveItemDataProps} item - The drive item (file or folder)
+ * @returns {string} The formatted display name
+ *
+ * @example
+ * // Folder
+ * getDisplayName({ name: 'Documents', isFolder: true }) // 'Documents'
+ *
+ * @example
+ * // File without extension in name
+ * getDisplayName({ name: 'report', type: 'pdf', isFolder: false }) // 'report.pdf'
+ *
+ * @example
+ * // File with extension in name (always appends type)
+ * getDisplayName({ name: 'backup.tar', type: 'tar', isFolder: false }) // 'backup.tar.tar'
+ *
+ * @example
+ * // File without type
+ * getDisplayName({ name: 'unknown', isFolder: false }) // 'unknown'
+ */
+export const getDisplayName = (item: DriveItemDataProps): string => {
+ if (item.isFolder || !item.type?.trim()) return item.name;
+
+ return `${item.name}.${item.type}`;
+};
diff --git a/src/hooks/useColor.ts b/src/hooks/useColor.ts
index e32a1de7b..8983db67f 100644
--- a/src/hooks/useColor.ts
+++ b/src/hooks/useColor.ts
@@ -1,4 +1,4 @@
-import { useColorScheme } from 'react-native';
+import { useTheme } from '@internxt-mobile/contexts/Theme';
import { useTailwind } from 'tailwind-rn';
const lightThemeColors = {
@@ -177,8 +177,8 @@ type ColorMap = typeof lightThemeColors;
const useGetColor = () => {
const tailwind = useTailwind();
- const colorScheme = useColorScheme();
- const isDark = colorScheme === 'dark';
+ const { theme } = useTheme();
+ const isDark = theme === 'dark';
const getColor = (textColorClass: string): string => {
const themeColors: ColorMap = isDark ? darkThemeColors : lightThemeColors;
diff --git a/src/hooks/useScreenProtection.spec.ts b/src/hooks/useScreenProtection.spec.ts
new file mode 100644
index 000000000..bdab7674c
--- /dev/null
+++ b/src/hooks/useScreenProtection.spec.ts
@@ -0,0 +1,199 @@
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+import { CaptureProtection } from 'react-native-capture-protection';
+import asyncStorageService from '../services/AsyncStorageService';
+import { logger } from '../services/common';
+import { useScreenProtection } from './useScreenProtection';
+
+jest.mock('react-native-capture-protection', () => ({
+ CaptureProtection: {
+ prevent: jest.fn(),
+ allow: jest.fn(),
+ },
+}));
+
+jest.mock('../services/AsyncStorageService', () => ({
+ getScreenProtectionEnabled: jest.fn(),
+ saveScreenProtectionEnabled: jest.fn(),
+}));
+
+jest.mock('../services/common', () => ({
+ logger: {
+ error: jest.fn(),
+ },
+}));
+
+describe('useScreenProtection', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('initialization', () => {
+ it('should initialize with protection enabled when saved preference is true', async () => {
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(true);
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ expect(result.current.isInitialized).toBe(false);
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(CaptureProtection.prevent).toHaveBeenCalledTimes(1);
+ expect(CaptureProtection.allow).not.toHaveBeenCalled();
+ expect(asyncStorageService.getScreenProtectionEnabled).toHaveBeenCalledTimes(1);
+ });
+
+ it('should initialize with protection disabled when saved preference is false', async () => {
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(false);
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ expect(result.current.isEnabled).toBe(false);
+ expect(CaptureProtection.allow).toHaveBeenCalledTimes(1);
+ expect(CaptureProtection.prevent).not.toHaveBeenCalled();
+ });
+
+ it('should default to enabled on initialization error', async () => {
+ const error = new Error('Storage error');
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockRejectedValue(error);
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(CaptureProtection.prevent).toHaveBeenCalledTimes(1);
+ expect(logger.error).toHaveBeenCalledWith('Error initializing screen protection:', error);
+ });
+ });
+
+ describe('setScreenProtection', () => {
+ beforeEach(async () => {
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(false);
+ });
+
+ it('should enable screen protection when called with true', async () => {
+ (asyncStorageService.saveScreenProtectionEnabled as jest.Mock).mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ await act(async () => {
+ await result.current.setScreenProtection(true);
+ });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(CaptureProtection.prevent).toHaveBeenCalled();
+ expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(true);
+ });
+
+ it('should disable screen protection when called with false', async () => {
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(true);
+ (asyncStorageService.saveScreenProtectionEnabled as jest.Mock).mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ await act(async () => {
+ await result.current.setScreenProtection(false);
+ });
+
+ expect(result.current.isEnabled).toBe(false);
+ expect(CaptureProtection.allow).toHaveBeenCalled();
+ expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(false);
+ });
+
+ it('should revert to enabled on failure', async () => {
+ const error = new Error('CaptureProtection error');
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(false);
+ (CaptureProtection.allow as jest.Mock).mockRejectedValue(error);
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ await act(async () => {
+ await result.current.setScreenProtection(false);
+ });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(CaptureProtection.prevent).toHaveBeenCalled();
+ expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(true);
+ expect(logger.error).toHaveBeenCalledWith('Error setting screen protection:', error);
+ });
+
+ it('should save preference after successfully changing protection state', async () => {
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(false);
+ (asyncStorageService.saveScreenProtectionEnabled as jest.Mock).mockResolvedValue(undefined);
+ (CaptureProtection.prevent as jest.Mock).mockResolvedValue(undefined);
+ (CaptureProtection.allow as jest.Mock).mockResolvedValue(undefined);
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ await act(async () => {
+ await result.current.setScreenProtection(true);
+ });
+
+ expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(true);
+ expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ await result.current.setScreenProtection(false);
+ });
+
+ expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledWith(false);
+ expect(asyncStorageService.saveScreenProtectionEnabled).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ describe('security defaults', () => {
+ it('should default to enabled for security if no preference is saved', async () => {
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockResolvedValue(true);
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(CaptureProtection.prevent).toHaveBeenCalled();
+ });
+
+ it('should enable protection on error for security', async () => {
+ (asyncStorageService.getScreenProtectionEnabled as jest.Mock).mockRejectedValue(new Error('Storage unavailable'));
+
+ const { result } = renderHook(() => useScreenProtection());
+
+ await waitFor(() => {
+ expect(result.current.isInitialized).toBe(true);
+ });
+
+ expect(result.current.isEnabled).toBe(true);
+ expect(CaptureProtection.prevent).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/src/hooks/useScreenProtection.ts b/src/hooks/useScreenProtection.ts
new file mode 100644
index 000000000..e7366f50f
--- /dev/null
+++ b/src/hooks/useScreenProtection.ts
@@ -0,0 +1,70 @@
+import { useEffect, useState } from 'react';
+import { CaptureProtection } from 'react-native-capture-protection';
+import asyncStorageService from '../services/AsyncStorageService';
+import { logger } from '../services/common';
+
+/**
+ * Hook to manage screen protection (prevents screenshots and screen recording)
+ * Handles initialization, state management, and persistence of user preference
+ */
+export const useScreenProtection = () => {
+ const [isEnabled, setIsEnabled] = useState(true);
+ const [isInitialized, setIsInitialized] = useState(false);
+
+ /**
+ * Initializes screen protection based on saved user preference
+ * Defaults to enabled (true) for security if no preference is saved
+ */
+ useEffect(() => {
+ const initialize = async () => {
+ try {
+ const savedPreference = await asyncStorageService.getScreenProtectionEnabled();
+ setIsEnabled(savedPreference);
+
+ if (savedPreference) {
+ await CaptureProtection.prevent();
+ } else {
+ await CaptureProtection.allow();
+ }
+
+ setIsInitialized(true);
+ } catch (error) {
+ logger.error('Error initializing screen protection:', error);
+ setIsEnabled(true);
+ await CaptureProtection.prevent();
+ setIsInitialized(true);
+ }
+ };
+
+ initialize();
+ }, []);
+
+ /**
+ * Enables or disables screen protection
+ * @param enabled - true to prevent screenshots/recording, false to allow
+ */
+ const setScreenProtection = async (enabled: boolean): Promise => {
+ try {
+ setIsEnabled(enabled);
+
+ if (enabled) {
+ await CaptureProtection.prevent();
+ } else {
+ await CaptureProtection.allow();
+ }
+
+ await asyncStorageService.saveScreenProtectionEnabled(enabled);
+ } catch (error) {
+ logger.error('Error setting screen protection:', error);
+ setIsEnabled(true);
+ await CaptureProtection.prevent();
+ await asyncStorageService.saveScreenProtectionEnabled(true);
+ }
+ };
+
+ return {
+ isEnabled,
+ isInitialized,
+ setScreenProtection,
+ };
+};
diff --git a/src/navigation/LinkingConfiguration.ts b/src/navigation/LinkingConfiguration.ts
index 13452700e..b2b83d076 100644
--- a/src/navigation/LinkingConfiguration.ts
+++ b/src/navigation/LinkingConfiguration.ts
@@ -8,7 +8,7 @@ import { LinkingOptions } from '@react-navigation/native';
import { RootStackParamList } from '../types/navigation';
const linking: LinkingOptions = {
- prefixes: ['inxt'],
+ prefixes: ['internxt://', 'inxt://'],
config: {
screens: {
Debug: 'debug',
@@ -16,6 +16,7 @@ const linking: LinkingOptions = {
SignUp: 'sign-up',
TabExplorer: 'tab-explorer',
ForgotPassword: 'forgot-password',
+ WebLogin: 'login-success',
},
},
};
diff --git a/src/navigation/RootNavigator.tsx b/src/navigation/RootNavigator.tsx
index be8d724e0..ab46a778a 100644
--- a/src/navigation/RootNavigator.tsx
+++ b/src/navigation/RootNavigator.tsx
@@ -8,6 +8,7 @@ import { uiActions } from 'src/store/slices/ui';
import ForgotPasswordScreen from '../screens/ForgotPasswordScreen';
import SignInScreen from '../screens/SignInScreen';
import SignUpScreen from '../screens/SignUpScreen';
+import WebLoginScreen from '../screens/WebLoginScreen';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { driveActions } from '../store/slices/drive';
import { RootStackParamList } from '../types/navigation';
@@ -79,6 +80,7 @@ function AppNavigator(): JSX.Element {
}}
/>
+
diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx
index f07401710..516fc905b 100644
--- a/src/navigation/index.tsx
+++ b/src/navigation/index.tsx
@@ -1,7 +1,6 @@
import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native';
import { useRef } from 'react';
import { View } from 'react-native';
-import analyticsService from '../services/AnalyticsService';
import LinkingConfiguration from './LinkingConfiguration';
import RootNavigator from './RootNavigator';
@@ -31,14 +30,9 @@ export default function Navigation() {
routeNameRef.current = currentRoute && currentRoute.name;
}}
- onStateChange={(route) => {
- const previousRouteName = routeNameRef.current;
+ onStateChange={() => {
const currentRouteName = navigationRef.getCurrentRoute()?.name;
- if (previousRouteName !== currentRouteName) {
- route && analyticsService.trackStackScreen(route, navigationRef.getCurrentRoute()?.params);
- }
-
routeNameRef.current = currentRouteName;
}}
>
diff --git a/src/network/upload.ts b/src/network/upload.ts
index 617d768bd..dcf069b5f 100644
--- a/src/network/upload.ts
+++ b/src/network/upload.ts
@@ -57,7 +57,6 @@ export async function uploadFile(
return await uploadPromise;
} catch (err) {
- console.warn(`Upload attempt ${attempt} of ${MAX_TRIES} failed:`, err);
logger.error(`Upload attempt ${attempt} of ${MAX_TRIES} failed:`, err);
const lastTryFailed = attempt === MAX_TRIES;
diff --git a/src/screens/SettingsScreen/index.tsx b/src/screens/SettingsScreen/index.tsx
index f476edc27..8a57d1e05 100644
--- a/src/screens/SettingsScreen/index.tsx
+++ b/src/screens/SettingsScreen/index.tsx
@@ -11,7 +11,7 @@ import {
Trash,
} from 'phosphor-react-native';
import { useEffect, useRef, useState } from 'react';
-import { Appearance, Linking, Platform, ScrollView, Switch, View } from 'react-native';
+import { Linking, Platform, ScrollView, Switch, View } from 'react-native';
import { storageSelectors } from 'src/store/slices/storage';
import { Language } from 'src/types';
@@ -24,6 +24,7 @@ import AppVersionWidget from '../../components/AppVersionWidget';
import SettingsGroup from '../../components/SettingsGroup';
import UserProfilePicture from '../../components/UserProfilePicture';
import useGetColor from '../../hooks/useColor';
+import { useScreenProtection } from '../../hooks/useScreenProtection';
import appService from '../../services/AppService';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import { authSelectors } from '../../store/slices/auth';
@@ -36,60 +37,25 @@ import { fs } from '@internxt-mobile/services/FileSystemService';
import { notifications } from '@internxt-mobile/services/NotificationsService';
import { internxtMobileSDKUtils } from '@internxt/mobile-sdk';
-import { CaptureProtection, useCaptureProtection } from 'react-native-capture-protection';
+import { useTheme } from '@internxt-mobile/contexts/Theme';
import { paymentsSelectors } from 'src/store/slices/payments';
-import asyncStorageService from '../../services/AsyncStorageService';
function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JSX.Element {
const [gettingLogs, setGettingLogs] = useState(false);
const tailwind = useTailwind();
const getColor = useGetColor();
const dispatch = useAppDispatch();
- const { protectionStatus } = useCaptureProtection();
const scrollViewRef = useRef(null);
+ const { theme, setTheme } = useTheme();
+ const isDarkMode = theme === 'dark';
- const [isDarkMode, setIsDarkMode] = useState(false);
- const [screenProtectionEnabled, setScreenProtectionEnabled] = useState(protectionStatus?.screenshot);
+ const { isEnabled: isScreenProtectionEnabled, setScreenProtection } = useScreenProtection();
const showBilling = useAppSelector(paymentsSelectors.shouldShowBilling);
const { user } = useAppSelector((state) => state.auth);
const usagePercent = useAppSelector(storageSelectors.usagePercent);
const [profileAvatar, setProfileAvatar] = useState();
const userFullName = useAppSelector(authSelectors.userFullName);
- useEffect(() => {
- const loadThemePreference = async () => {
- try {
- const savedTheme = await asyncStorageService.getThemePreference();
-
- if (savedTheme) {
- setIsDarkMode(savedTheme === 'dark');
-
- Appearance.setColorScheme(savedTheme);
- } else {
- const systemTheme = Appearance.getColorScheme() || 'light';
- setIsDarkMode(systemTheme === 'dark');
- }
- } catch (error) {
- const systemTheme = Appearance.getColorScheme() || 'light';
- setIsDarkMode(systemTheme === 'dark');
- }
- };
-
- loadThemePreference();
- }, []);
-
- useEffect(() => {
- const subscription = Appearance.addChangeListener(({ colorScheme: newColorScheme }) => {
- asyncStorageService.getThemePreference().then((savedTheme) => {
- if (!savedTheme && newColorScheme) {
- setIsDarkMode(newColorScheme === 'dark');
- }
- });
- });
-
- return () => subscription?.remove();
- }, []);
-
useEffect(() => {
if (!user?.avatar) {
return setProfileAvatar(undefined);
@@ -114,38 +80,12 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
}, [user?.avatar]);
const handleDarkModeToggle = async (value: boolean) => {
- try {
- const newTheme = value ? 'dark' : 'light';
- setIsDarkMode(value);
-
- await asyncStorageService.saveThemePreference(newTheme);
-
- if (Platform.OS === 'android') {
- setTimeout(() => {
- Appearance.setColorScheme(newTheme);
- }, 100);
- } else {
- Appearance.setColorScheme(newTheme);
- }
- } catch (error) {
- setIsDarkMode(!value);
- Appearance.setColorScheme(!value ? 'dark' : 'light');
- }
+ const newTheme = value ? 'dark' : 'light';
+ await setTheme(newTheme);
};
const handleScreenProtection = async (value: boolean) => {
- try {
- setScreenProtectionEnabled(value);
-
- if (value) {
- await CaptureProtection.prevent();
- } else {
- await CaptureProtection.allow();
- }
- } catch (error) {
- setScreenProtectionEnabled(true);
- await CaptureProtection.prevent();
- }
+ await setScreenProtection(value);
};
const onAccountPressed = () => {
@@ -385,7 +325,7 @@ function SettingsScreen({ navigation }: SettingsScreenProps<'SettingsHome'>): JS
thumbColor={isDarkMode ? getColor('text-white') : getColor('text-gray-40')}
ios_backgroundColor={getColor('text-gray-20')}
onValueChange={handleScreenProtection}
- value={screenProtectionEnabled}
+ value={isScreenProtectionEnabled}
/>
diff --git a/src/screens/SignInScreen/index.tsx b/src/screens/SignInScreen/index.tsx
index 1fa9986c2..c973c297d 100644
--- a/src/screens/SignInScreen/index.tsx
+++ b/src/screens/SignInScreen/index.tsx
@@ -1,159 +1,91 @@
-import { Eye, EyeSlash, WarningCircle } from 'phosphor-react-native';
-import { useEffect, useRef, useState } from 'react';
-import { Animated, TextInput, View } from 'react-native';
+import { useState } from 'react';
+import { Linking, View } from 'react-native';
import { useKeyboard } from '@internxt-mobile/hooks/useKeyboard';
-import validationService from '@internxt-mobile/services/ValidationService';
-import { ScrollView, TouchableWithoutFeedback } from 'react-native-gesture-handler';
+import { WarningCircle } from 'phosphor-react-native';
+import { ScrollView } from 'react-native-gesture-handler';
+
import AppText from 'src/components/AppText';
import { useTailwind } from 'tailwind-rn';
import strings from '../../../assets/lang/strings';
import AppButton from '../../components/AppButton';
import AppScreen from '../../components/AppScreen';
-import AppTextInput from '../../components/AppTextInput';
import AppVersionWidget from '../../components/AppVersionWidget';
import useGetColor from '../../hooks/useColor';
import analytics, { AnalyticsEventKey } from '../../services/AnalyticsService';
-import authService from '../../services/AuthService';
+import appService from '../../services/AppService';
+import { logger } from '../../services/common';
import errorService from '../../services/ErrorService';
-import { useAppDispatch } from '../../store/hooks';
-import { authThunks } from '../../store/slices/auth';
+import notificationsService from '../../services/NotificationsService';
+import { NotificationType } from '../../types';
import { RootStackScreenProps } from '../../types/navigation';
-const MAX_ERROR_MESSAGE_LENGTH = 200;
-
function SignInScreen({ navigation }: RootStackScreenProps<'SignIn'>): JSX.Element {
const tailwind = useTailwind();
const getColor = useGetColor();
- const dispatch = useAppDispatch();
const { keyboardShown } = useKeyboard();
- const [isSubmitted, setIsSubmitted] = useState(false);
- const [errors, setErrors] = useState>({});
- const [isLoading, setIsLoading] = useState(false);
- const [email, setEmail] = useState('');
- const [password, setPassword] = useState('');
- const [twoFactorCode, setTwoFactorCode] = useState('');
- const [showTwoFactor, setShowTwoFactor] = useState(false);
- const [showPasswordText, setShowPasswordText] = useState(false);
- const animatedHeight = useRef(new Animated.Value(0));
- const passwordInputRef = useRef(null);
- const [failed2FA, setFailed2FA] = useState(false);
-
- useEffect(() => {
- if (!twoFactorCode.length) {
- setFailed2FA(false);
- }
- }, [twoFactorCode]);
+ const [error, setError] = useState('');
- useEffect(() => {
- if (showTwoFactor) {
- Animated.timing(animatedHeight.current, {
- duration: 150,
- toValue: 66,
- useNativeDriver: false,
- }).start();
- } else {
- Animated.timing(animatedHeight.current, {
- duration: 150,
- toValue: 0,
- useNativeDriver: false,
- }).start();
+ 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 errorMessage = errorService.castError(err).message;
+ logger.error('Error opening web auth URL', err);
+ setError(errorMessage);
}
- }, [showTwoFactor]);
-
- useEffect(() => {
- setIsSubmitted(false);
- setErrors({});
- }, [email, password]);
-
- const focusPassword = () => {
- passwordInputRef.current?.focus();
};
-
- const onSignInButtonPressed = async () => {
- setIsSubmitted(true);
- if (!email || !password) {
- setErrors({ email: strings.errors.missingAuthCredentials });
- return;
- }
-
- setIsLoading(true);
- if (!validationService.validateEmail(email)) {
- setErrors({ email: strings.errors.validEmail });
- return;
- }
-
- setFailed2FA(false);
- let requires2FA = false;
-
+ const onSignUpWithBrowserPressed = async () => {
try {
- setErrors({});
- requires2FA = await authService.is2FAEnabled(email);
+ const webAuthUrl = appService.urls.webAuth.signup;
- if (requires2FA && !twoFactorCode) {
- setShowTwoFactor(true);
- setIsLoading(false);
- return;
- }
- setIsLoading(true);
- const result = await authService.doLogin(email.toLowerCase(), password, twoFactorCode);
- await dispatch(
- authThunks.signInThunk({ user: result.user, token: result.token, newToken: result.newToken }),
- ).unwrap();
+ const canOpen = await Linking.canOpenURL(webAuthUrl);
- analytics.identify(result.user.uuid, { email: result.user.email });
- analytics.track(AnalyticsEventKey.UserSignIn, {
- email,
- });
- setTimeout(() => {
- setIsLoading(false);
- navigation.replace('TabExplorer', { screen: 'Home' });
- }, 1000);
- } catch (err) {
- if (requires2FA) {
- setFailed2FA(true);
- }
- let errorMessage;
- try {
- const castedError = errorService.castError(err);
- errorMessage = castedError.message;
+ if (canOpen) {
+ await Linking.openURL(webAuthUrl);
- if (errorMessage.length > MAX_ERROR_MESSAGE_LENGTH) errorMessage = strings.errors.genericError;
- } catch (castingError) {
- errorMessage = strings.errors.genericError;
+ analytics.track(AnalyticsEventKey.UserSignUp, {
+ method: 'browser',
+ });
+ } else {
+ notificationsService.show({
+ type: NotificationType.Error,
+ text1: strings.screens.SignUpScreen.errorOpeningLink,
+ });
}
-
- analytics.track(AnalyticsEventKey.UserSignInFailed, {
- email,
- message: errorMessage,
- });
-
- setIsLoading(false);
- setErrors({ loginFailed: errorMessage });
+ } catch (err) {
+ const errorMessage = errorService.castError(err).message;
+ logger.error('Error opening web sign up URL', err);
+ setError(errorMessage);
}
};
- const onGoToSignUpButtonPressed = () => {
- setErrors({});
- navigation.navigate('SignUp');
- };
-
const renderErrorMessage = () => {
- if (errors['loginFailed'] || errors['email'] || (errors['password'] && isSubmitted)) {
- const errorMessage = failed2FA
- ? strings.errors.failed2FA
- : errors['email'] || errors['password'] || errors['loginFailed'];
- return (
-
-
- {errorMessage}
-
- );
+ if (!error) {
+ return null;
}
- };
- const hasErrors = (errors['loginFailed'] || errors['email'] || errors['password']) && isSubmitted;
+ return (
+
+
+ {error}
+
+ );
+ };
return (
): JSX.Eleme
-
- setEmail(value)}
- placeholder={strings.inputs.email}
- maxLength={64}
- keyboardType="email-address"
- autoCapitalize={'none'}
- returnKeyType="next"
- autoCorrect={false}
- autoComplete="username"
- textContentType="emailAddress"
- editable={!isLoading}
- onSubmitEditing={focusPassword}
- />
-
- (
- setShowPasswordText(!showPasswordText)}>
-
- {showPasswordText ? (
-
- ) : (
-
- )}
-
-
- )}
- />
-
-
-
-
-
-
-
- navigation.navigate('ForgotPassword')}>
-
-
- {strings.screens.SignInScreen.forgot}
-
-
-
-
-
-
- {strings.screens.SignInScreen.no_register}{' '}
+ {strings.screens.SignInScreen.no_register}
+ {renderErrorMessage()}
{keyboardShown ? null : }
diff --git a/src/screens/WebLoginScreen/index.tsx b/src/screens/WebLoginScreen/index.tsx
new file mode 100644
index 000000000..146784c41
--- /dev/null
+++ b/src/screens/WebLoginScreen/index.tsx
@@ -0,0 +1,123 @@
+import { CheckCircle, WarningCircle } from 'phosphor-react-native';
+import { useEffect, useState } from 'react';
+import { View } from 'react-native';
+import { useTailwind } from 'tailwind-rn';
+import strings from '../../../assets/lang/strings';
+import AppScreen from '../../components/AppScreen';
+import AppText from '../../components/AppText';
+import useGetColor from '../../hooks/useColor';
+import authService from '../../services/AuthService';
+import { logger } from '../../services/common';
+import errorService from '../../services/ErrorService';
+import { useAppDispatch } from '../../store/hooks';
+import { authThunks } from '../../store/slices/auth';
+import { RootStackScreenProps } from '../../types/navigation';
+
+type LoadingState = 'processing' | 'success' | 'error';
+
+function WebLoginScreen({ route, navigation }: RootStackScreenProps<'WebLogin'>): JSX.Element {
+ const tailwind = useTailwind();
+ const getColor = useGetColor();
+ const dispatch = useAppDispatch();
+ const [loadingState, setLoadingState] = useState('processing');
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const handleDeepLink = async () => {
+ const params = route.params;
+
+ if (!params) {
+ navigation.replace('SignIn');
+ return;
+ }
+
+ const { mnemonic, token, newToken, privateKey } = params;
+
+ if (!mnemonic || !token || !newToken) {
+ setLoadingState('error');
+ setError(strings.screens.WebLoginScreen.missingParameters);
+ logger.error('WebLoginScreen: Missing required parameters', !mnemonic || !token || !newToken);
+ setTimeout(() => navigation.replace('SignIn'), 2000);
+ return;
+ }
+
+ try {
+ const result = await authService.handleWebLogin({
+ mnemonic,
+ token,
+ newToken,
+ privateKey,
+ });
+
+ await dispatch(
+ authThunks.signInThunk({
+ user: result.user,
+ token: result.token,
+ newToken: result.newToken,
+ }),
+ ).unwrap();
+
+ setLoadingState('success');
+ setTimeout(() => navigation.replace('TabExplorer', { screen: 'Home' }), 800);
+ } catch (error) {
+ const castedError = errorService.castError(error);
+ setLoadingState('error');
+ setError(castedError.message);
+ logger.error('WebLoginScreen error', error);
+
+ setTimeout(() => navigation.replace('SignIn'), 2000);
+ }
+ };
+
+ handleDeepLink();
+ }, [route.params, dispatch, navigation]);
+
+ const renderContent = () => {
+ switch (loadingState) {
+ case 'processing':
+ return (
+
+
+ {strings.screens.WebLoginScreen.processing}
+
+
+ );
+
+ case 'success':
+ return (
+
+
+
+
+
+ {strings.screens.WebLoginScreen.success}
+
+
+ );
+
+ case 'error':
+ return (
+
+
+
+
+
+ {error || strings.screens.WebLoginScreen.authenticationFailed}
+
+
+ );
+ }
+ };
+
+ return (
+
+ {renderContent()}
+
+ );
+}
+
+export default WebLoginScreen;
diff --git a/src/screens/drive/DrivePreviewScreen/DrivePreviewScreen.tsx b/src/screens/drive/DrivePreviewScreen/DrivePreviewScreen.tsx
index 480f21aff..97af97a55 100644
--- a/src/screens/drive/DrivePreviewScreen/DrivePreviewScreen.tsx
+++ b/src/screens/drive/DrivePreviewScreen/DrivePreviewScreen.tsx
@@ -5,6 +5,7 @@ import { fs } from '@internxt-mobile/services/FileSystemService';
import { notifications } from '@internxt-mobile/services/NotificationsService';
import { FileExtension } from '@internxt-mobile/types/drive';
import { RootStackScreenProps } from '@internxt-mobile/types/navigation';
+import { Thumbnail } from '@internxt/sdk/dist/drive/storage/types';
import strings from 'assets/lang/strings';
import { WarningCircle } from 'phosphor-react-native';
import React, { useEffect, useRef, useState } from 'react';
@@ -16,6 +17,7 @@ import AppScreen from 'src/components/AppScreen';
import AppText from 'src/components/AppText';
import { DEFAULT_EASING } from 'src/components/modals/SharedLinkSettingsModal/animations';
import { getFileTypeIcon } from 'src/helpers';
+import { useDrive } from 'src/hooks/drive';
import { useAppDispatch, useAppSelector } from 'src/store/hooks';
import { uiActions } from 'src/store/slices/ui';
import { getLineHeight } from 'src/styles/global';
@@ -25,11 +27,12 @@ import { DriveImagePreview } from './DriveImagePreview';
import { DrivePdfPreview } from './DrivePdfPreview';
import { DRIVE_PREVIEW_HEADER_HEIGHT, DrivePreviewScreenHeader } from './DrivePreviewScreenHeader';
import { DriveVideoPreview } from './DriveVideoPreview';
+import { useThumbnailRegeneration } from './hooks/useThumbnailRegeneration';
import AnimatedLoadingDots from './LoadingDots';
-const IMAGE_PREVIEW_TYPES = [FileExtension.PNG, FileExtension.JPG, FileExtension.JPEG, FileExtension.HEIC];
-const VIDEO_PREVIEW_TYPES = [FileExtension.MP4, FileExtension.MOV, FileExtension.AVI];
-const PDF_PREVIEW_TYPES = [FileExtension.PDF];
+const IMAGE_PREVIEW_TYPES = new Set([FileExtension.PNG, FileExtension.JPG, FileExtension.JPEG, FileExtension.HEIC]);
+const VIDEO_PREVIEW_TYPES = new Set([FileExtension.MP4, FileExtension.MOV, FileExtension.AVI]);
+const PDF_PREVIEW_TYPES = new Set([FileExtension.PDF]);
export const DrivePreviewScreen: React.FC> = (props) => {
const tailwind = useTailwind();
@@ -40,6 +43,7 @@ export const DrivePreviewScreen: React.FC>
// REDUX USAGE STARTS
const insets = useSafeAreaInsets();
const dispatch = useAppDispatch();
+ const driveCtx = useDrive();
// REDUX USAGE ENDS
const { downloadingFile } = useAppSelector((state) => state.drive);
// Use this in order to listen for state changes
@@ -51,7 +55,7 @@ export const DrivePreviewScreen: React.FC>
useEffect(() => {
if (
downloadingFile?.downloadedFilePath &&
- VIDEO_PREVIEW_TYPES.includes(downloadingFile.data.type as FileExtension) &&
+ VIDEO_PREVIEW_TYPES.has(downloadingFile.data.type as FileExtension) &&
!generatedThumbnail
) {
imageService
@@ -63,6 +67,32 @@ export const DrivePreviewScreen: React.FC>
}
}, [downloadingFile?.downloadedFilePath]);
+ const updateItemWithNewThumbnail = (thumbnail: Thumbnail) => {
+ if (!focusedItem?.folderUuid) return;
+
+ driveCtx.updateItemInTree(focusedItem.folderUuid, focusedItem.id, {
+ thumbnails: [thumbnail],
+ });
+ dispatch(
+ driveActions.setFocusedItem({
+ ...focusedItem,
+ thumbnails: [thumbnail],
+ }),
+ );
+ };
+
+ useThumbnailRegeneration(
+ {
+ downloadedFilePath: downloadingFile?.downloadedFilePath,
+ fileExtension: focusedItem?.type,
+ fileUuid: focusedItem?.uuid,
+ hasThumbnails: !!(focusedItem?.thumbnails && focusedItem.thumbnails.length > 0),
+ },
+ {
+ onSuccess: updateItemWithNewThumbnail,
+ },
+ );
+
useEffect(() => {
Animated.timing(topbarYPosition, {
toValue: topbarVisible ? 0 : -totalHeaderHeight,
@@ -91,11 +121,13 @@ export const DrivePreviewScreen: React.FC>
return <>>;
}
const filename = `${focusedItem.name || ''}${focusedItem.type ? `.${focusedItem.type}` : ''}`;
- const currentProgress = downloadingFile.downloadProgress * 0.95 + downloadingFile.decryptProgress * 0.05;
+ const currentProgress =
+ (downloadingFile.downloadProgress ?? 0) * 0.95 + (downloadingFile.decryptProgress ?? 0) * 0.05;
const FileIcon = getFileTypeIcon(focusedItem.type || '');
- const hasImagePreview = IMAGE_PREVIEW_TYPES.includes(downloadingFile.data.type?.toLowerCase() as FileExtension);
- const hasVideoPreview = VIDEO_PREVIEW_TYPES.includes(downloadingFile.data.type?.toLowerCase() as FileExtension);
- const hasPdfPreview = PDF_PREVIEW_TYPES.includes(downloadingFile.data.type?.toLowerCase() as FileExtension);
+ const fileType = downloadingFile.data.type?.toLowerCase();
+ const hasImagePreview = fileType ? IMAGE_PREVIEW_TYPES.has(fileType as FileExtension) : false;
+ const hasVideoPreview = fileType ? VIDEO_PREVIEW_TYPES.has(fileType as FileExtension) : false;
+ const hasPdfPreview = fileType ? PDF_PREVIEW_TYPES.has(fileType as FileExtension) : false;
const getProgressMessage = () => {
if (!downloadingFile) {
@@ -141,7 +173,7 @@ export const DrivePreviewScreen: React.FC>
{!isDownloaded && !error ? (
) : null}
@@ -161,7 +193,7 @@ export const DrivePreviewScreen: React.FC>
style={tailwind('mt-5')}
title={strings.buttons.tryAgain}
type={'white'}
- onPress={() => downloadingFile.retry && downloadingFile.retry()}
+ onPress={() => downloadingFile?.retry?.()}
>
)}
@@ -246,10 +278,10 @@ export const DrivePreviewScreen: React.FC>
{
- dispatch(driveThunks.cancelDownloadThunk());
- props.navigation.goBack();
+ onCloseButtonPress={async () => {
+ await dispatch(driveThunks.cancelDownloadThunk());
dispatch(driveActions.clearDownloadingFile());
+ props.navigation.goBack();
}}
onActionsButtonPress={handleActionsButtonPress}
/>
diff --git a/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.spec.ts b/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.spec.ts
new file mode 100644
index 000000000..620040957
--- /dev/null
+++ b/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.spec.ts
@@ -0,0 +1,99 @@
+jest.mock('@internxt-mobile/services/drive/file', () => ({
+ driveFileService: {
+ regenerateThumbnail: jest.fn(),
+ },
+}));
+
+jest.mock('@internxt-mobile/services/common', () => ({
+ logger: {
+ info: jest.fn(),
+ },
+}));
+
+jest.mock('@internxt-mobile/services/ErrorService', () => ({
+ reportError: jest.fn(),
+}));
+
+import { canGenerateThumbnail, shouldRegenerateThumbnail } from './useThumbnailRegeneration';
+
+describe('useThumbnailRegeneration', () => {
+ describe('canGenerateThumbnail', () => {
+ it('should return true for image types', () => {
+ expect(canGenerateThumbnail('png')).toBe(true);
+ expect(canGenerateThumbnail('PNG')).toBe(true);
+ expect(canGenerateThumbnail('jpg')).toBe(true);
+ expect(canGenerateThumbnail('jpeg')).toBe(true);
+ expect(canGenerateThumbnail('HEIC')).toBe(true);
+ });
+
+ it('should return true for video types', () => {
+ expect(canGenerateThumbnail('mp4')).toBe(true);
+ expect(canGenerateThumbnail('MP4')).toBe(true);
+ expect(canGenerateThumbnail('mov')).toBe(true);
+ expect(canGenerateThumbnail('avi')).toBe(true);
+ });
+
+ it('should return true for PDF', () => {
+ expect(canGenerateThumbnail('pdf')).toBe(true);
+ expect(canGenerateThumbnail('PDF')).toBe(true);
+ });
+
+ it('should return false for unsupported types', () => {
+ expect(canGenerateThumbnail('txt')).toBe(false);
+ expect(canGenerateThumbnail('docx')).toBe(false);
+ expect(canGenerateThumbnail('zip')).toBe(false);
+ });
+ });
+
+ describe('shouldRegenerateThumbnail', () => {
+ it('should return true when file is downloaded, has no thumbnails, and is supported type', () => {
+ const result = shouldRegenerateThumbnail({
+ downloadedFilePath: '/path/to/file.jpg',
+ fileExtension: 'jpg',
+ fileUuid: 'uuid-123',
+ hasThumbnails: false,
+ });
+ expect(result).toBe(true);
+ });
+
+ it('should return false when file already has thumbnails', () => {
+ const result = shouldRegenerateThumbnail({
+ downloadedFilePath: '/path/to/file.jpg',
+ fileExtension: 'jpg',
+ fileUuid: 'uuid-123',
+ hasThumbnails: true,
+ });
+ expect(result).toBe(false);
+ });
+
+ it('should return false when file is not downloaded', () => {
+ const result = shouldRegenerateThumbnail({
+ downloadedFilePath: undefined,
+ fileExtension: 'jpg',
+ fileUuid: 'uuid-123',
+ hasThumbnails: false,
+ });
+ expect(result).toBe(false);
+ });
+
+ it('should return false when file type is unsupported', () => {
+ const result = shouldRegenerateThumbnail({
+ downloadedFilePath: '/path/to/file.txt',
+ fileExtension: 'txt',
+ fileUuid: 'uuid-123',
+ hasThumbnails: false,
+ });
+ expect(result).toBe(false);
+ });
+
+ it('should return false when file extension is missing', () => {
+ const result = shouldRegenerateThumbnail({
+ downloadedFilePath: '/path/to/file',
+ fileExtension: undefined,
+ fileUuid: 'uuid-123',
+ hasThumbnails: false,
+ });
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.ts b/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.ts
new file mode 100644
index 000000000..97d48cd4e
--- /dev/null
+++ b/src/screens/drive/DrivePreviewScreen/hooks/useThumbnailRegeneration.ts
@@ -0,0 +1,95 @@
+import { logger } from '@internxt-mobile/services/common';
+import { driveFileService } from '@internxt-mobile/services/drive/file';
+import errorService from '@internxt-mobile/services/ErrorService';
+import { FileExtension } from '@internxt-mobile/types/drive';
+import { Thumbnail } from '@internxt/sdk/dist/drive/storage/types';
+import { useEffect, useRef, useState } from 'react';
+
+const IMAGE_PREVIEW_TYPES = new Set([FileExtension.PNG, FileExtension.JPG, FileExtension.JPEG, FileExtension.HEIC]);
+const VIDEO_PREVIEW_TYPES = new Set([FileExtension.MP4, FileExtension.MOV, FileExtension.AVI]);
+const PDF_PREVIEW_TYPES = new Set([FileExtension.PDF]);
+
+interface ThumbnailRegenerationParams {
+ downloadedFilePath?: string;
+ fileExtension?: string;
+ fileUuid?: string;
+ hasThumbnails: boolean;
+}
+
+interface ThumbnailRegenerationCallbacks {
+ onSuccess: (thumbnail: Thumbnail) => void;
+}
+
+export const canGenerateThumbnail = (fileExtension: string): boolean => {
+ const extension = fileExtension.toLowerCase() as FileExtension;
+ return IMAGE_PREVIEW_TYPES.has(extension) || VIDEO_PREVIEW_TYPES.has(extension) || PDF_PREVIEW_TYPES.has(extension);
+};
+
+export const shouldRegenerateThumbnail = (params: ThumbnailRegenerationParams): boolean => {
+ const { downloadedFilePath, fileExtension, hasThumbnails } = params;
+
+ if (!downloadedFilePath || hasThumbnails || !fileExtension) {
+ return false;
+ }
+
+ return canGenerateThumbnail(fileExtension);
+};
+
+export const regenerateThumbnail = async (
+ fileUuid: string,
+ downloadedFilePath: string,
+ fileExtension: string,
+): Promise => {
+ try {
+ const thumbnail = await driveFileService.regenerateThumbnail(
+ fileUuid,
+ downloadedFilePath,
+ fileExtension.toLowerCase(),
+ );
+ logger.info('Thumbnail regenerated successfully', { thumbnail });
+ return thumbnail;
+ } catch (error) {
+ logger.info('Thumbnail regeneration error', { error });
+ errorService.reportError(error);
+ return null;
+ }
+};
+
+export const useThumbnailRegeneration = (
+ params: ThumbnailRegenerationParams,
+ callbacks: ThumbnailRegenerationCallbacks,
+) => {
+ const [isRegenerating, setIsRegenerating] = useState(false);
+ const regenerationAttempted = useRef(false);
+
+ useEffect(() => {
+ const { downloadedFilePath, fileExtension, fileUuid } = params;
+
+ if (regenerationAttempted.current || isRegenerating) {
+ return;
+ }
+
+ if (!shouldRegenerateThumbnail(params)) {
+ return;
+ }
+
+ if (!fileUuid || !downloadedFilePath || !fileExtension) {
+ return;
+ }
+
+ regenerationAttempted.current = true;
+ setIsRegenerating(true);
+
+ regenerateThumbnail(fileUuid, downloadedFilePath, fileExtension)
+ .then((thumbnail) => {
+ if (thumbnail) {
+ callbacks.onSuccess(thumbnail);
+ }
+ })
+ .finally(() => {
+ setIsRegenerating(false);
+ });
+ }, [params.downloadedFilePath, params.hasThumbnails]);
+
+ return { isRegenerating, regenerationAttempted: regenerationAttempted.current };
+};
diff --git a/src/services/AnalyticsService.ts b/src/services/AnalyticsService.ts
index 764baee00..0578ee1a9 100644
--- a/src/services/AnalyticsService.ts
+++ b/src/services/AnalyticsService.ts
@@ -1,9 +1,3 @@
-import { NavigationState } from '@react-navigation/native';
-import analytics from '@rudderstack/rudder-sdk-react-native';
-import axios from 'axios';
-
-import { getHeaders } from '../helpers/headers';
-import { constants } from './AppService';
import { BaseLogger } from './common';
export type JsonMap = Record;
@@ -32,121 +26,19 @@ export enum DriveAnalyticsEvent {
FileDownloadError = 'File Download Error',
FileDownloadCompleted = 'File Download Completed',
}
-export class AnalyticsService {
- private config = {
- trackEnabled: true,
- trackAppLifeCycleEvents: true,
- screenTrackEnabled: true,
- identifyEnabled: true,
- };
+export class AnalyticsService {
private logger = new BaseLogger({
tag: 'DRIVE_ANALYTICS',
disabled: !__DEV__,
});
- public getClient() {
- return analytics;
- }
- public async setup() {
- const WRITEKEY = constants.ANALYTICS_WRITE_KEY as string;
-
- if (!WRITEKEY) {
- // eslint-disable-next-line no-console
- console.warn('No WRITEKEY Key provided');
- }
-
- await analytics.setup(WRITEKEY, {
- dataPlaneUrl: constants.DATAPLANE_URL,
- recordScreenViews: this.config.screenTrackEnabled,
- trackAppLifecycleEvents: this.config.trackAppLifeCycleEvents,
- });
- }
-
- public identify(
- user: string,
- traits: Record = {},
- options: Record = {},
- ) {
- if (!this.config.identifyEnabled) return this.asNoop();
- this.logger.info('User identified');
- return analytics.identify(user, traits, options);
- }
-
- public track(event: AnalyticsEventKey | DriveAnalyticsEvent, properties?: JsonMap, options?: Options) {
- if (!this.config.trackEnabled) return this.asNoop();
- analytics.track(event, properties, options);
- this.logger.info(`"${event}" event tracked`);
- }
-
- public screen(name: string, properties?: JsonMap, options?: Options) {
- if (!this.config.screenTrackEnabled) return this.asNoop();
- this.logger.info(`"${name}" screen tracked`);
- analytics.screen(name, properties, options);
- }
-
- public async trackStackScreen(state: NavigationState, params?: any): Promise {
- if (!this.config.screenTrackEnabled) return this.asNoop();
- analytics.screen(state.routes[0].name, params);
- }
-
- public async getCheckoutSessionById(sessionId: string): Promise {
- const headers = await getHeaders();
- const headersMap: Record = {};
-
- headers.forEach((value: string, key: string) => {
- headersMap[key] = value;
- });
-
- return axios
- .get(`${constants.DRIVE_API_URL}/stripe/session/?sessionId=${sessionId}`, {
- headers: headersMap,
- })
- .then((res) => {
- return res.data;
- });
- }
-
- public async getConversionDataProperties(sessionId: string) {
- const session = await this.getCheckoutSessionById(sessionId);
-
- if (session.payment_status === 'paid') {
- const amount = session.amount_total * 0.01;
-
- return {
- price_id: session.metadata.price_id,
- email: session.customer_details.email,
- product: session.metadata.product,
- customer_id: session.customer,
- currency: session.currency.toUpperCase(),
- value: amount,
- revenue: amount,
- quantity: 1,
- type: session.metadata.type,
- plan_name: session.metadata.name,
- impact_value: amount === 0 ? 5 : amount,
- subscription_id: session.subscription,
- payment_intent: session.payment_intent,
- };
- }
-
- return null;
- }
-
- public async trackPayment(sessionId: string): Promise {
- const conversionData = await this.getConversionDataProperties(sessionId);
-
- if (conversionData) {
- await this.track(AnalyticsEventKey.PaymentConversion, conversionData);
- }
- }
-
- public async testEvent() {
- await analytics.track('Test Event');
+ public identify(_user: string, _traits: Record = {}) {
+ this.logger.info('User identified', _user, _traits);
}
- private async asNoop() {
- return;
+ public track(event: AnalyticsEventKey | DriveAnalyticsEvent, properties?: JsonMap) {
+ this.logger.info(`"${event}" event tracked`, properties);
}
}
diff --git a/src/services/AppService.ts b/src/services/AppService.ts
index 1400454b9..1f16f85b7 100644
--- a/src/services/AppService.ts
+++ b/src/services/AppService.ts
@@ -31,6 +31,10 @@ class AppService {
public get urls() {
return {
termsAndConditions: 'https://internxt.com/legal',
+ webAuth: {
+ login: `${this.constants.WEB_CLIENT_URL}/login?universalLink=true`,
+ signup: `${this.constants.WEB_CLIENT_URL}/new?universalLink=true`,
+ },
};
}
diff --git a/src/services/AsyncStorageService.ts b/src/services/AsyncStorageService.ts
index ad5921215..21023eb7d 100644
--- a/src/services/AsyncStorageService.ts
+++ b/src/services/AsyncStorageService.ts
@@ -98,6 +98,20 @@ class AsyncStorageService {
return this.saveItem(AsyncStorageKey.LastSecurityCheck, date.toISOString());
}
+ /**
+ * Gets the screen protection preference (prevents screenshots/screen recording)
+ * @returns {Promise} true if protection is enabled, defaults to true for security if not set
+ */
+ async getScreenProtectionEnabled(): Promise {
+ const screenProtectionEnabled = await this.getItem(AsyncStorageKey.ScreenProtectionEnabled);
+
+ return screenProtectionEnabled === null ? true : screenProtectionEnabled === 'true';
+ }
+
+ saveScreenProtectionEnabled(enabled: boolean): Promise {
+ return this.saveItem(AsyncStorageKey.ScreenProtectionEnabled, enabled.toString());
+ }
+
async clearStorage(): Promise {
try {
const nonSensitiveKeys = [
diff --git a/src/services/AuthService.ts b/src/services/AuthService.ts
index d6de1bc2d..62b66dcfd 100644
--- a/src/services/AuthService.ts
+++ b/src/services/AuthService.ts
@@ -4,6 +4,7 @@ import { StorageTypes } from '@internxt/sdk/dist/drive';
import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
import EventEmitter from 'events';
import jwtDecode from 'jwt-decode';
+import { validateMnemonic } from 'react-native-bip39';
import { decryptText, decryptTextWithKey, encryptText, encryptTextWithKey, passToHash } from '../helpers';
import AesUtils from '../helpers/aesUtils';
import { getHeaders } from '../helpers/headers';
@@ -114,6 +115,45 @@ class AuthService {
};
}
+ public async handleWebLogin(params: { mnemonic: string; token: string; newToken: string; privateKey?: string }) {
+ try {
+ const mnemonic = Buffer.from(params.mnemonic, 'base64').toString('utf-8');
+ const newToken = Buffer.from(params.newToken, 'base64').toString('utf-8');
+ const privateKey = params.privateKey ? Buffer.from(params.privateKey, 'base64').toString('utf-8') : undefined;
+
+ const isMnemonicValid = validateMnemonic(mnemonic);
+ if (!isMnemonicValid) {
+ throw new Error('Invalid mnemonic phrase');
+ }
+
+ const refreshedLoginData = await this.refreshAuthToken(newToken);
+
+ if (!refreshedLoginData?.token || !refreshedLoginData?.newToken) {
+ throw new Error('Unable to refresh auth tokens');
+ }
+
+ if (!refreshedLoginData.user) {
+ throw new Error('Failed to fetch user data');
+ }
+
+ const userData = refreshedLoginData.user;
+
+ const user = {
+ ...userData,
+ mnemonic,
+ privateKey: privateKey || userData.privateKey,
+ };
+
+ return {
+ user,
+ token: refreshedLoginData.token,
+ newToken: refreshedLoginData.newToken,
+ };
+ } catch (error) {
+ throw new Error(`Web login failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ }
+ }
+
public async signout(): Promise {
analytics.track(AnalyticsEventKey.UserLogout);
await asyncStorageService.clearStorage();
@@ -344,14 +384,16 @@ class AuthService {
* @param currentAuthToken The current auth token, needs to be still valid
* @returns A valid set of token and newToken
*/
- public async refreshAuthToken(currentAuthToken: string): Promise<{ newToken: string; token: string } | undefined> {
+ public async refreshAuthToken(
+ currentAuthToken: string,
+ ): Promise<{ newToken: string; token: string; user: UserSettings } | undefined> {
const result = await fetch(`${appService.constants.DRIVE_NEW_API_URL}/users/refresh`, {
method: 'GET',
headers: await getHeaders(currentAuthToken),
});
-
const body = await result.json();
- const { newToken, token, statusCode } = body;
+
+ const { newToken, token, user } = body;
if (!result.ok) {
throw new Error('Tokens no longer valid, should sign out');
@@ -360,6 +402,7 @@ class AuthService {
return {
newToken,
token,
+ user,
};
}
diff --git a/src/services/drive/file/driveFile.service.ts b/src/services/drive/file/driveFile.service.ts
index ca65dcfc7..97649ad9f 100644
--- a/src/services/drive/file/driveFile.service.ts
+++ b/src/services/drive/file/driveFile.service.ts
@@ -3,14 +3,18 @@ import { DownloadedThumbnail, DriveListItem, GetModifiedFiles, SortDirection, So
import { constants } from '../../AppService';
import asyncStorageService from '@internxt-mobile/services/AsyncStorageService';
-import { SdkManager } from '@internxt-mobile/services/common';
+import { imageService, SdkManager } from '@internxt-mobile/services/common';
import fileSystemService, { fs } from '@internxt-mobile/services/FileSystemService';
import { Abortable, AsyncStorageKey } from '@internxt-mobile/types/index';
-import { MoveFileUuidPayload } from '@internxt/sdk/dist/drive/storage/types';
+import { EncryptionVersion, FileMeta, Thumbnail } from '@internxt/sdk/dist/drive/storage/types';
import { UserSettings } from '@internxt/sdk/dist/shared/types/userSettings';
+import { SaveFormat } from 'expo-image-manipulator';
import { Image } from 'react-native';
+import uuid from 'react-native-uuid';
import { getEnvironmentConfig } from 'src/lib/network';
import * as networkDownload from 'src/network/download';
+import network from '../../../network';
+import { uploadService } from '../../common/network/upload/upload.service';
import { DRIVE_THUMBNAILS_DIRECTORY } from '../constants';
import { driveFileCache } from './driveFileCache.service';
@@ -93,8 +97,14 @@ class DriveFileService {
});
}
- public async moveFile(moveFilePayload: MoveFileUuidPayload) {
- return this.sdk.storageV2.moveFileByUuid(moveFilePayload);
+ public async moveFile({
+ fileUuid,
+ destinationFolderUuid,
+ }: {
+ fileUuid: string;
+ destinationFolderUuid: string;
+ }): Promise {
+ return this.sdk.storageV2.moveFileByUuid(fileUuid, { destinationFolder: destinationFolderUuid });
}
public getSortFunction({
@@ -194,9 +204,18 @@ class DriveFileService {
return parsedModifiedFiles;
}
- public async getThumbnail(thumbnail: { bucket_id: string; bucket_file: string; type: string }) {
+ public async getThumbnail(thumbnail: {
+ bucket_id: string;
+ bucket_file: string;
+ bucketFile: string;
+ bucketId: string;
+ type: string;
+ }) {
const { bridgeUser, bridgePass, encryptionKey } = await getEnvironmentConfig();
- const destination = `${DRIVE_THUMBNAILS_DIRECTORY}/${thumbnail.bucket_file}.${thumbnail.type}`;
+ // To handle that server not returns bucket_id and bucket_file when just generated the thumbnail
+ const bucketFile = thumbnail.bucket_file ? thumbnail.bucket_file.toString() : thumbnail.bucketFile.toString();
+ const bucketId = thumbnail.bucket_id ? thumbnail.bucket_id : thumbnail.bucketId;
+ const destination = `${DRIVE_THUMBNAILS_DIRECTORY}/${bucketFile}.${thumbnail.type}`;
const measureThumbnail = (path: string) => {
return new Promise((resolve, reject) => {
@@ -216,8 +235,8 @@ class DriveFileService {
}
await networkDownload.downloadThumbnail(
- thumbnail.bucket_file.toString(),
- thumbnail.bucket_id,
+ bucketFile,
+ bucketId,
encryptionKey,
{
user: bridgeUser,
@@ -329,6 +348,61 @@ class DriveFileService {
public async checkFileExistence(parentFolderUuid: string, filesList: { plainName: string; type: string }[]) {
return this.sdk.storageV2.checkDuplicatedFiles({ folderUuid: parentFolderUuid, filesList });
}
+
+ /**
+ * Generates and uploads a thumbnail for a file
+ * @param fileUuid UUID of the file to create thumbnail for
+ * @param filePath Local path to the file
+ * @param fileExtension Extension of the file
+ * @returns The created thumbnail entry or null if generation fails
+ */
+ public async regenerateThumbnail(
+ fileUuid: string,
+ filePath: string,
+ fileExtension: string,
+ ): Promise {
+ try {
+ const generatedThumbnail = await imageService.generateThumbnail(filePath.replace(/ /g, '%20'), {
+ extension: fileExtension,
+ thumbnailFormat: SaveFormat.JPEG,
+ outputPath: fileSystemService.tmpFilePath(`${uuid.v4()}.${SaveFormat.JPEG}`),
+ });
+
+ if (!generatedThumbnail) {
+ return null;
+ }
+
+ const { bucket, bridgeUser, mnemonic, userId } = await asyncStorageService.getUser();
+ const thumbnailFileId = await network.uploadFile(
+ generatedThumbnail.path,
+ bucket,
+ mnemonic,
+ constants.BRIDGE_URL,
+ {
+ user: bridgeUser,
+ pass: userId,
+ },
+ {},
+ );
+
+ const uploadedThumbnail = await uploadService.createThumbnailEntry({
+ fileUuid: fileUuid,
+ maxWidth: generatedThumbnail.width,
+ maxHeight: generatedThumbnail.height,
+ type: generatedThumbnail.type,
+ size: generatedThumbnail.size,
+ bucketId: bucket,
+ bucketFile: thumbnailFileId,
+ encryptVersion: EncryptionVersion.Aes03,
+ });
+
+ await fs.unlinkIfExists(generatedThumbnail.path);
+
+ return uploadedThumbnail;
+ } catch (error) {
+ return null;
+ }
+ }
}
export const driveFileService = new DriveFileService(SdkManager.getInstance());
diff --git a/src/services/drive/file/utils/checkDuplicatedFiles.ts b/src/services/drive/file/utils/checkDuplicatedFiles.ts
index 2b98030c8..95ec798c8 100644
--- a/src/services/drive/file/utils/checkDuplicatedFiles.ts
+++ b/src/services/drive/file/utils/checkDuplicatedFiles.ts
@@ -18,6 +18,8 @@ export interface File {
uri: string;
size: number;
type?: string;
+ modificationTime?: string;
+ creationTime?: string;
}
export const checkDuplicatedFiles = async (files: File[], parentFolderUuid: string): Promise => {
diff --git a/src/services/drive/file/utils/exifHelpers.spec.ts b/src/services/drive/file/utils/exifHelpers.spec.ts
new file mode 100644
index 000000000..ff0d0ec67
--- /dev/null
+++ b/src/services/drive/file/utils/exifHelpers.spec.ts
@@ -0,0 +1,284 @@
+jest.mock('../..', () => ({
+ __esModule: true,
+ default: {
+ file: {
+ getExtensionFromUri: jest.fn(),
+ },
+ },
+}));
+
+import drive from '../..';
+import {
+ clearGeneratedNamesCache,
+ generateFileName,
+ isTemporaryFileName,
+ isVideoExtension,
+ parseExifDate,
+} from './exifHelpers';
+
+const mockGetExtensionFromUri = drive.file.getExtensionFromUri as jest.MockedFunction<
+ typeof drive.file.getExtensionFromUri
+>;
+
+describe('parseExifDate', () => {
+ test('should parse valid EXIF date format', () => {
+ const exifDate = '2024:12:25 14:30:45';
+ const result = parseExifDate(exifDate);
+ expect(result).toMatch(/2024-12-25T\d{2}:30:45\.\d{3}Z/);
+ });
+
+ test('should return undefined for undefined input', () => {
+ const result = parseExifDate(undefined);
+ expect(result).toBeUndefined();
+ });
+
+ test('should return undefined for invalid date', () => {
+ const result = parseExifDate('invalid:date:format');
+ expect(result).toBeUndefined();
+ });
+
+ test('should return undefined for malformed EXIF date', () => {
+ const result = parseExifDate('2024:13:45 25:99:99');
+ expect(result).toBeUndefined();
+ });
+
+ test('should handle partial EXIF dates', () => {
+ const result = parseExifDate('2024:01:15');
+ expect(result).toBeDefined();
+ });
+});
+
+describe('isVideoExtension', () => {
+ test('should return true for video extensions', () => {
+ expect(isVideoExtension('mp4')).toBe(true);
+ expect(isVideoExtension('mov')).toBe(true);
+ expect(isVideoExtension('avi')).toBe(true);
+ });
+
+ test('should return false for image extensions', () => {
+ expect(isVideoExtension('jpg')).toBe(false);
+ expect(isVideoExtension('png')).toBe(false);
+ expect(isVideoExtension('heic')).toBe(false);
+ expect(isVideoExtension('gif')).toBe(false);
+ });
+
+ test('should handle uppercase extensions', () => {
+ expect(isVideoExtension('MP4')).toBe(true);
+ expect(isVideoExtension('MOV')).toBe(true);
+ expect(isVideoExtension('JPG')).toBe(false);
+ });
+
+ test('should return false for other file types', () => {
+ expect(isVideoExtension('pdf')).toBe(false);
+ expect(isVideoExtension('txt')).toBe(false);
+ expect(isVideoExtension('doc')).toBe(false);
+ });
+
+ test('should return true for additional video formats', () => {
+ expect(isVideoExtension('mkv')).toBe(true);
+ expect(isVideoExtension('webm')).toBe(true);
+ expect(isVideoExtension('m4v')).toBe(true);
+ expect(isVideoExtension('3gp')).toBe(true);
+ expect(isVideoExtension('flv')).toBe(true);
+ });
+
+ test('should return false for unsupported extensions', () => {
+ expect(isVideoExtension('xyz')).toBe(false);
+ expect(isVideoExtension('unknown')).toBe(false);
+ expect(isVideoExtension('abc')).toBe(false);
+ });
+});
+
+describe('isTemporaryFileName', () => {
+ test('should return true for numeric filenames', () => {
+ expect(isTemporaryFileName('12345.jpg')).toBe(true);
+ expect(isTemporaryFileName('987654321.png')).toBe(true);
+ expect(isTemporaryFileName('1234.mp4')).toBe(true);
+ });
+
+ test('should return true for undefined or null', () => {
+ expect(isTemporaryFileName(undefined)).toBe(true);
+ expect(isTemporaryFileName(null)).toBe(true);
+ });
+
+ test('should return true for empty string', () => {
+ expect(isTemporaryFileName('')).toBe(true);
+ });
+
+ test('should return false for descriptive filenames', () => {
+ expect(isTemporaryFileName('IMG_20240512_143045.jpg')).toBe(false);
+ expect(isTemporaryFileName('photo_2024.png')).toBe(false);
+ expect(isTemporaryFileName('vacation.mp4')).toBe(false);
+ });
+
+ test('should return false for filenames with letters', () => {
+ expect(isTemporaryFileName('abc123.jpg')).toBe(false);
+ expect(isTemporaryFileName('12abc.png')).toBe(false);
+ });
+
+ test('should handle different file extensions', () => {
+ expect(isTemporaryFileName('12345.heic')).toBe(true);
+ expect(isTemporaryFileName('67890.mov')).toBe(true);
+ expect(isTemporaryFileName('11111.webp')).toBe(true);
+ });
+
+ test('should return false for filenames without extension', () => {
+ expect(isTemporaryFileName('12345')).toBe(false);
+ });
+});
+
+describe('generateFileName', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ clearGeneratedNamesCache();
+ mockGetExtensionFromUri.mockReturnValue('jpg');
+ });
+
+ test('should generate filename without milliseconds for first file', () => {
+ const uri = 'file:///path/to/image.jpg';
+ const creationTime = '2024-12-25T14:30:45.123Z';
+
+ const result = generateFileName(uri, creationTime);
+
+ expect(result).toBe('IMG_20241225_143045.jpg');
+ expect(mockGetExtensionFromUri).toHaveBeenCalledWith(uri);
+ });
+
+ test('should use modificationTime over creationTime', () => {
+ const uri = 'file:///path/to/image.jpg';
+ const creationTime = '2024-12-25T14:30:45.000Z';
+ const modificationTime = '2024-12-25T15:30:45.000Z';
+
+ const result = generateFileName(uri, creationTime, modificationTime);
+
+ expect(result).toContain('153045');
+ });
+
+ test('should handle duplicate timestamps by adding milliseconds then counter', () => {
+ const uri1 = 'file:///path/to/image1.jpg';
+ const uri2 = 'file:///path/to/image2.jpg';
+ const uri3 = 'file:///path/to/image3.jpg';
+ const sameTimestamp = '2024-12-25T14:30:45.123Z';
+
+ const result1 = generateFileName(uri1, sameTimestamp);
+ const result2 = generateFileName(uri2, sameTimestamp);
+ const result3 = generateFileName(uri3, sameTimestamp);
+
+ expect(result1).toBe('IMG_20241225_143045.jpg');
+ expect(result2).toBe('IMG_20241225_143045-123.jpg');
+ expect(result3).toBe('IMG_20241225_143045-123_2.jpg');
+ });
+
+ test('should fallback to Date.now() when no time provided', () => {
+ const uri = 'file:///path/to/image.jpg';
+
+ const result = generateFileName(uri);
+
+ expect(result).toMatch(/^IMG_\d{8}_\d{6}\.jpg$/);
+ });
+
+ test('should handle invalid dates and fallback to current time', () => {
+ const uri = 'file:///path/to/image.jpg';
+ const invalidTime = 'invalid-date';
+
+ const result = generateFileName(uri, invalidTime);
+
+ expect(result).toMatch(/^IMG_\d{8}_\d{6}\.jpg$/);
+ });
+
+ test('should handle different file extensions', () => {
+ mockGetExtensionFromUri.mockReturnValue('png');
+ const uri = 'file:///path/to/image.png';
+ const creationTime = '2024-12-25T14:30:45.123Z';
+
+ const result = generateFileName(uri, creationTime);
+
+ expect(result).toMatch(/\.png$/);
+ });
+
+ test('should use VID prefix for video files', () => {
+ mockGetExtensionFromUri.mockReturnValue('mp4');
+ const uri = 'file:///path/to/video.mp4';
+ const creationTime = '2024-12-25T14:30:45.123Z';
+
+ const result = generateFileName(uri, creationTime);
+
+ expect(result).toBe('VID_20241225_143045.mp4');
+ });
+
+ test('should use VID prefix for MOV files', () => {
+ mockGetExtensionFromUri.mockReturnValue('mov');
+ const uri = 'file:///path/to/video.mov';
+ const creationTime = '2024-12-25T14:30:45.123Z';
+
+ const result = generateFileName(uri, creationTime);
+
+ expect(result).toBe('VID_20241225_143045.mov');
+ });
+
+ test('should use IMG prefix for image files', () => {
+ mockGetExtensionFromUri.mockReturnValue('heic');
+ const uri = 'file:///path/to/photo.heic';
+ const creationTime = '2024-12-25T14:30:45.123Z';
+
+ const result = generateFileName(uri, creationTime);
+
+ expect(result).toBe('IMG_20241225_143045.heic');
+ });
+
+ test('should use fallback when formatting fails', () => {
+ const uri = 'file:///path/to/image.jpg';
+
+ const originalToISOString = Date.prototype.toISOString;
+ let callCount = 0;
+ Date.prototype.toISOString = jest.fn(() => {
+ callCount++;
+ if (callCount === 1) {
+ throw new Error('Formatting error');
+ }
+ return originalToISOString.call(new Date());
+ });
+
+ const result = generateFileName(uri);
+
+ expect(result).toMatch(/^IMG_\d{8}_\d{6}\.jpg$/);
+
+ Date.prototype.toISOString = originalToISOString;
+ });
+
+ test('should handle bulk upload scenario with 20 files', () => {
+ const sameTimestamp = '2024-12-25T14:30:45.123Z';
+ const results: string[] = [];
+
+ for (let i = 0; i < 20; i++) {
+ const uri = `file:///path/to/image${i}.jpg`;
+ results.push(generateFileName(uri, sameTimestamp));
+ }
+
+ const uniqueNames = new Set(results);
+ expect(uniqueNames.size).toBe(20);
+
+ expect(results[0]).toBe('IMG_20241225_143045.jpg');
+ expect(results[1]).toBe('IMG_20241225_143045-123.jpg');
+ expect(results[2]).toBe('IMG_20241225_143045-123_2.jpg');
+ expect(results[19]).toBe('IMG_20241225_143045-123_19.jpg');
+ });
+
+ test('should handle duplicate video files with VID prefix', () => {
+ mockGetExtensionFromUri.mockReturnValue('mp4');
+ clearGeneratedNamesCache();
+
+ const uri1 = 'file:///path/to/video1.mp4';
+ const uri2 = 'file:///path/to/video2.mp4';
+ const uri3 = 'file:///path/to/video3.mp4';
+ const sameTimestamp = '2024-12-25T14:30:45.456Z';
+
+ const result1 = generateFileName(uri1, sameTimestamp);
+ const result2 = generateFileName(uri2, sameTimestamp);
+ const result3 = generateFileName(uri3, sameTimestamp);
+
+ expect(result1).toBe('VID_20241225_143045.mp4');
+ expect(result2).toBe('VID_20241225_143045-456.mp4');
+ expect(result3).toBe('VID_20241225_143045-456_2.mp4');
+ });
+});
diff --git a/src/services/drive/file/utils/exifHelpers.ts b/src/services/drive/file/utils/exifHelpers.ts
new file mode 100644
index 000000000..6637895cd
--- /dev/null
+++ b/src/services/drive/file/utils/exifHelpers.ts
@@ -0,0 +1,221 @@
+import drive from '../..';
+
+const generatedNamesCache = new Map();
+const CACHE_CLEANUP_INTERVAL = 60000; // 1 minute
+
+if (typeof jest === 'undefined') {
+ setInterval(() => {
+ generatedNamesCache.clear();
+ }, CACHE_CLEANUP_INTERVAL);
+}
+
+/**
+ * Parses EXIF date string (format: "YYYY:MM:DD HH:MM:SS") to ISO string
+ * @param exifDate - EXIF date string
+ * @returns ISO date string or undefined if parsing fails
+ * @note Invalid dates are handled by returning undefined (no error thrown)
+ */
+export const parseExifDate = (exifDate: string | undefined): string | undefined => {
+ if (!exifDate) return undefined;
+
+ try {
+ const fixedDate = exifDate.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3');
+ const parsedDate = new Date(fixedDate);
+ return Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate.toISOString();
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Checks if filename is a temporary numeric name
+ * @param fileName - Original filename
+ * @returns true if it's a temporary name
+ */
+export const isTemporaryFileName = (fileName: string | undefined | null): boolean => {
+ if (!fileName) return true;
+ return /^\d+\.\w+$/i.test(fileName);
+};
+
+/**
+ * Clears the generated filenames cache
+ * @note This function is primarily used for testing purposes
+ */
+export const clearGeneratedNamesCache = (): void => {
+ generatedNamesCache.clear();
+};
+
+/**
+ * Resolves timestamp from provided dates or falls back to current time
+ * @param creationTime - ISO string of creation time
+ * @param modificationTime - ISO string of modification time
+ * @returns Valid Date object, never returns invalid date
+ */
+const resolveTimestamp = (creationTime?: string, modificationTime?: string): Date => {
+ try {
+ const dateString = modificationTime || creationTime;
+ const timestamp = dateString ? new Date(dateString) : new Date();
+
+ if (Number.isNaN(timestamp.getTime())) {
+ return new Date();
+ }
+
+ return timestamp;
+ } catch {
+ return new Date();
+ }
+};
+
+/**
+ * Formats timestamp into clean date and time strings without separators
+ * @param timestamp - Date object to format
+ * @returns Object with formatted dateStr, timeStr, and msStr
+ */
+const formatTimestampParts = (timestamp: Date): { dateStr: string; timeStr: string; msStr: string } => {
+ const isoString = timestamp.toISOString();
+ const [datePart, timePart] = isoString.split('T');
+ const [timeWithSeconds, milliseconds] = timePart.split('.');
+
+ return {
+ dateStr: datePart.replaceAll('-', ''),
+ timeStr: timeWithSeconds.replaceAll(':', ''),
+ msStr: milliseconds.slice(0, 3),
+ };
+};
+
+/**
+ * Builds filename variants with and without milliseconds
+ * @param dateStr - Formatted date string (YYYYMMDD)
+ * @param timeStr - Formatted time string (HHMMSS)
+ * @param msStr - Milliseconds string (mmm)
+ * @param extension - File extension
+ * @returns Object with base name variants
+ */
+const buildFileNameVariants = (
+ dateStr: string,
+ timeStr: string,
+ msStr: string,
+ extension: string,
+): { withoutMs: string; withMs: string } => {
+ const prefix = getFilePrefix(extension);
+ return {
+ withoutMs: `${prefix}_${dateStr}_${timeStr}.${extension}`,
+ withMs: `${prefix}_${dateStr}_${timeStr}-${msStr}.${extension}`,
+ };
+};
+
+/**
+ * Resolves unique filename by checking cache and adding suffixes as needed
+ * @param baseNameWithoutMs - Clean filename without milliseconds
+ * @param baseNameWithMs - Filename with milliseconds
+ * @param dateStr - Formatted date string
+ * @param timeStr - Formatted time string
+ * @param msStr - Milliseconds string
+ * @param extension - File extension
+ * @returns Unique filename with appropriate suffix
+ */
+const resolveUniqueFileName = (
+ baseNameWithoutMs: string,
+ baseNameWithMs: string,
+ dateStr: string,
+ timeStr: string,
+ msStr: string,
+ extension: string,
+): string => {
+ const countWithoutMs = generatedNamesCache.get(baseNameWithoutMs) || 0;
+
+ const firstTimeName = countWithoutMs === 0;
+ if (firstTimeName) {
+ generatedNamesCache.set(baseNameWithoutMs, 1);
+ return baseNameWithoutMs;
+ }
+
+ const countWithMs = generatedNamesCache.get(baseNameWithMs) || 0;
+
+ const nameWithMsNotExists = countWithMs === 0;
+ if (nameWithMsNotExists) {
+ generatedNamesCache.set(baseNameWithMs, 1);
+ return baseNameWithMs;
+ }
+
+ const prefix = getFilePrefix(extension);
+ const finalName = `${prefix}_${dateStr}_${timeStr}-${msStr}_${countWithMs + 1}.${extension}`;
+ generatedNamesCache.set(baseNameWithMs, countWithMs + 1);
+ return finalName;
+};
+
+const VIDEO_EXTENSIONS = [
+ 'mp4',
+ 'mov',
+ 'avi',
+ 'mkv',
+ 'webm',
+ 'm4v',
+ '3gp',
+ 'flv',
+ 'wmv',
+ 'mpeg',
+ 'mpg',
+ 'ogv',
+ 'mts',
+ 'm2ts',
+ 'ts',
+ 'vob',
+ 'asf',
+];
+
+/**
+ * Checks if the file extension corresponds to a video format
+ * Based on video extensions defined in helpers/filetypes.ts plus common video formats
+ * @param extension - File extension
+ * @returns true if video format, false otherwise
+ */
+export const isVideoExtension = (extension: string): boolean => {
+ return VIDEO_EXTENSIONS.includes(extension.toLowerCase());
+};
+
+/**
+ * Gets the appropriate file prefix based on extension
+ * @param extension - File extension
+ * @returns 'VID' for videos, 'IMG' for images and others
+ */
+const getFilePrefix = (extension: string): string => {
+ return isVideoExtension(extension) ? 'VID' : 'IMG';
+};
+
+/**
+ * Generates fallback filename when main generation fails
+ * @param uri - File URI to extract extension
+ * @returns Simple fallback filename
+ */
+const generateFallbackFileName = (uri: string): string => {
+ const extension = drive.file.getExtensionFromUri(uri)?.toLowerCase() ?? 'jpg';
+ const prefix = getFilePrefix(extension);
+ const { dateStr, timeStr } = formatTimestampParts(new Date());
+ return `${prefix}_${dateStr}_${timeStr}.${extension}`;
+};
+
+/**
+ * Generates a descriptive filename for media files
+ * @param uri - File URI to extract extension
+ * @param creationTime - ISO string of creation time
+ * @param modificationTime - ISO string of modification time
+ * @returns Generated filename in format:
+ * - IMG_YYYYMMDD_HHMMSS.extension (for images)
+ * - VID_YYYYMMDD_HHMMSS.extension (for videos)
+ * With -mmm suffix if duplicate, or -mmm_N if multiple duplicates
+ * @note Uses cache to prevent duplicates during bulk uploads
+ */
+export const generateFileName = (uri: string, creationTime?: string, modificationTime?: string): string => {
+ try {
+ const timestamp = resolveTimestamp(creationTime, modificationTime);
+ const { dateStr, timeStr, msStr } = formatTimestampParts(timestamp);
+ const extension = drive.file.getExtensionFromUri(uri)?.toLowerCase() ?? 'jpg';
+
+ const { withoutMs, withMs } = buildFileNameVariants(dateStr, timeStr, msStr, extension);
+
+ return resolveUniqueFileName(withoutMs, withMs, dateStr, timeStr, msStr, extension);
+ } catch {
+ return generateFallbackFileName(uri);
+ }
+};
diff --git a/src/services/drive/file/utils/prepareFilesToUpload.ts b/src/services/drive/file/utils/prepareFilesToUpload.ts
index d3782c03f..494a2948d 100644
--- a/src/services/drive/file/utils/prepareFilesToUpload.ts
+++ b/src/services/drive/file/utils/prepareFilesToUpload.ts
@@ -1,5 +1,5 @@
import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types';
-import { DocumentPickerResponse } from 'react-native-document-picker';
+import { DocumentPickerFile } from '../../../../types/drive';
import { checkDuplicatedFiles } from './checkDuplicatedFiles';
import { processDuplicateFiles } from './processDuplicateFiles';
@@ -9,6 +9,8 @@ export interface FileToUpload {
size: number;
type: string;
parentUuid: string;
+ modificationTime?: string;
+ creationTime?: string;
}
const BATCH_SIZE = 200;
@@ -19,7 +21,7 @@ export const prepareFilesToUpload = async ({
disableDuplicatedNamesCheck = false,
disableExistenceCheck = false,
}: {
- files: DocumentPickerResponse[];
+ files: DocumentPickerFile[];
parentFolderUuid: string;
disableDuplicatedNamesCheck?: boolean;
disableExistenceCheck?: boolean;
@@ -28,7 +30,7 @@ export const prepareFilesToUpload = async ({
let zeroLengthFilesNumber = 0;
const processFiles = async (
- filesBatch: DocumentPickerResponse[],
+ filesBatch: DocumentPickerFile[],
disableDuplicatedNamesCheckOverride: boolean,
duplicatedFiles?: DriveFileData[],
) => {
@@ -44,7 +46,7 @@ export const prepareFilesToUpload = async ({
zeroLengthFilesNumber += zeroLengthFiles;
};
- const processFilesBatch = async (filesBatch: DocumentPickerResponse[]) => {
+ const processFilesBatch = async (filesBatch: DocumentPickerFile[]) => {
if (disableExistenceCheck) {
await processFiles(filesBatch, true);
} else {
@@ -53,6 +55,8 @@ export const prepareFilesToUpload = async ({
uri: f.uri,
size: f.size,
type: f.type ?? '',
+ modificationTime: f.modificationTime,
+ creationTime: f.creationTime,
}));
const { duplicatedFilesResponse, filesWithoutDuplicates, filesWithDuplicates } = await checkDuplicatedFiles(
@@ -60,9 +64,9 @@ export const prepareFilesToUpload = async ({
parentFolderUuid,
);
- await processFiles(filesWithoutDuplicates as DocumentPickerResponse[], true);
+ await processFiles(filesWithoutDuplicates as DocumentPickerFile[], true);
await processFiles(
- filesWithDuplicates as DocumentPickerResponse[],
+ filesWithDuplicates as DocumentPickerFile[],
disableDuplicatedNamesCheck,
duplicatedFilesResponse,
);
diff --git a/src/services/drive/file/utils/processDuplicateFiles.ts b/src/services/drive/file/utils/processDuplicateFiles.ts
index ca44000fe..ef6837657 100644
--- a/src/services/drive/file/utils/processDuplicateFiles.ts
+++ b/src/services/drive/file/utils/processDuplicateFiles.ts
@@ -1,5 +1,6 @@
import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types';
import { DocumentPickerResponse } from 'react-native-document-picker';
+import { DocumentPickerFile } from '../../../../types/drive';
import { getUniqueFilename } from './getUniqueFilename';
import { FileToUpload } from './prepareFilesToUpload';
@@ -24,7 +25,7 @@ export const processDuplicateFiles = async ({
const zeroLengthFiles = files.filter((file) => file.size === 0).length;
const newFilesToUpload: FileToUpload[] = [...existingFilesToUpload];
- const processFile = async (file: DocumentPickerResponse): Promise => {
+ const processFile = async (file: DocumentPickerFile): Promise => {
if (file.size === 0) return;
const { plainName, extension } = getFilenameAndExt(file.name);
@@ -40,6 +41,8 @@ export const processDuplicateFiles = async ({
type: extension ?? file.type ?? '',
uri: file.uri,
parentUuid: parentFolderUuid,
+ modificationTime: file.modificationTime ?? undefined,
+ creationTime: file.creationTime ?? undefined,
});
};
diff --git a/src/services/drive/file/utils/uploadFileUtils.ts b/src/services/drive/file/utils/uploadFileUtils.ts
index c9c2eb73d..a6476c9be 100644
--- a/src/services/drive/file/utils/uploadFileUtils.ts
+++ b/src/services/drive/file/utils/uploadFileUtils.ts
@@ -4,7 +4,7 @@ import uuid from 'react-native-uuid';
import strings from '../../../../../assets/lang/strings';
import { isValidFilename } from '../../../../helpers';
import { driveActions } from '../../../../store/slices/drive';
-import { UPLOAD_FILE_SIZE_LIMIT, UploadingFile } from '../../../../types/drive';
+import { DocumentPickerFile, UPLOAD_FILE_SIZE_LIMIT, UploadingFile } from '../../../../types/drive';
import { checkDuplicatedFiles, File } from './checkDuplicatedFiles';
import { FileToUpload, prepareFilesToUpload } from './prepareFilesToUpload';
@@ -20,12 +20,12 @@ import notificationsService from '../../../NotificationsService';
/**
* Validate file names and filter out files exceeding the upload size limit.
*
- * @param {DocumentPickerResponse[]} documents - Array of selected documents.
- * @returns {{ filesToUpload: DocumentPickerResponse[], filesExcluded: DocumentPickerResponse[] }}
+ * @param {DocumentPickerFile[]} documents - Array of selected documents.
+ * @returns {{ filesToUpload: DocumentPickerFile[], filesExcluded: DocumentPickerFile[] }}
*/
-export function validateAndFilterFiles(documents: DocumentPickerResponse[]) {
- const filesToUpload: DocumentPickerResponse[] = [];
- const filesExcluded: DocumentPickerResponse[] = [];
+export function validateAndFilterFiles(documents: DocumentPickerFile[]) {
+ const filesToUpload: DocumentPickerFile[] = [];
+ const filesExcluded: DocumentPickerFile[] = [];
if (!documents.every((file) => isValidFilename(file.name))) {
throw new Error('Some file names are not valid');
@@ -45,9 +45,9 @@ export function validateAndFilterFiles(documents: DocumentPickerResponse[]) {
/**
* Show an alert when some files exceed the upload size limit.
*
- * @param {DocumentPickerResponse[]} filesExcluded - Files that were excluded due to size.
+ * @param {DocumentPickerFile[]} filesExcluded - Files that were excluded due to size.
*/
-export function showFileSizeAlert(filesExcluded: DocumentPickerResponse[]) {
+export function showFileSizeAlert(filesExcluded: DocumentPickerFile[]) {
if (filesExcluded.length === 0) return;
const messageKey = filesExcluded.length === 1 ? strings.messages.uploadFileLimit : strings.messages.uploadFilesLimit;
@@ -58,30 +58,28 @@ export function showFileSizeAlert(filesExcluded: DocumentPickerResponse[]) {
/**
* Handle duplicate files by checking for existing files in the target folder and optionally prompting the user.
*
- * @param {DocumentPickerResponse[]} files - Files to check for duplication.
+ * @param {DocumentPickerFile[]} files - Files to check for duplication.
* @param {string} folderUuid - UUID of the destination folder.
- * @returns {Promise} - Files to proceed with after handling duplicates.
+ * @returns {Promise} - Files to proceed with after handling duplicates.
*/
export async function handleDuplicateFiles(
- files: DocumentPickerResponse[],
+ files: DocumentPickerFile[],
folderUuid: string,
-): Promise {
+): Promise {
const mappedFiles = files.map((file) => ({
- name: file.name,
- uri: file.uri,
- size: file.size,
+ ...file,
type: file.type ?? '',
}));
const { filesWithoutDuplicates, filesWithDuplicates } = await checkDuplicatedFiles(mappedFiles, folderUuid);
- let filesToProcess = [...filesWithoutDuplicates] as DocumentPickerResponse[];
+ let filesToProcess = [...filesWithoutDuplicates] as DocumentPickerFile[];
if (filesWithDuplicates.length > 0) {
const shouldUploadDuplicates = await askUserAboutDuplicates(filesWithDuplicates);
if (shouldUploadDuplicates) {
- filesToProcess = [...filesToProcess, ...(filesWithDuplicates as DocumentPickerResponse[])];
+ filesToProcess = [...filesToProcess, ...(filesWithDuplicates as DocumentPickerFile[])];
}
}
@@ -163,6 +161,8 @@ export function createUploadingFiles(
size: preparedFile.size,
progress: 0,
uploaded: false,
+ modificationTime: preparedFile.modificationTime,
+ creationTime: preparedFile.creationTime,
};
formattedFiles.push(fileToUpload);
diff --git a/src/services/drive/folder/driveFolder.service.ts b/src/services/drive/folder/driveFolder.service.ts
index 6b83b0f58..be2db33a1 100644
--- a/src/services/drive/folder/driveFolder.service.ts
+++ b/src/services/drive/folder/driveFolder.service.ts
@@ -1,5 +1,3 @@
-import { MoveFolderUuidPayload } from '@internxt/sdk/dist/drive/storage/types';
-
import asyncStorageService from '@internxt-mobile/services/AsyncStorageService';
import { SdkManager } from '@internxt-mobile/services/common';
import { AsyncStorageKey } from '@internxt-mobile/types/index';
@@ -34,8 +32,14 @@ class DriveFolderService {
return sdkResult ? sdkResult[0] : Promise.reject('createFolder Sdk method did not return a valid result');
}
- public async moveFolder(payload: MoveFolderUuidPayload) {
- return this.sdk.storageV2.moveFolderByUuid(payload);
+ public async moveFolder({
+ destinationFolderUuid,
+ folderUuid,
+ }: {
+ folderUuid: string;
+ destinationFolderUuid: string;
+ }) {
+ return this.sdk.storageV2.moveFolderByUuid(folderUuid, { destinationFolder: destinationFolderUuid });
}
public async updateMetaData(folderUuid: string, newName: string): Promise {
diff --git a/src/services/drive/trash/driveTrash.service.ts b/src/services/drive/trash/driveTrash.service.ts
index d8b895b49..07401019e 100644
--- a/src/services/drive/trash/driveTrash.service.ts
+++ b/src/services/drive/trash/driveTrash.service.ts
@@ -84,8 +84,16 @@ class DriveTrashService {
return this.sdk.trash.clearTrash();
}
- public async moveToTrash(items: { id: number | string; type: 'folder' | 'file' }[]) {
+ public async moveToTrash(items: { id: number | string; type: 'folder' | 'file'; uuid?: string }[]) {
const itemsToMove = items.map((item) => {
+ if (item.uuid !== undefined) {
+ return {
+ id: null,
+ uuid: item.uuid,
+ type: item.type,
+ };
+ }
+
return {
id: item.id,
type: item.type,
diff --git a/src/types/app.ts b/src/types/app.ts
index 4a259ec19..d87484956 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -20,7 +20,6 @@ export interface AppEnv {
SENTRY_URL: string;
SENTRY_AUTH_TOKEN: string;
DATAPLANE_URL: string;
- ANALYTICS_WRITE_KEY: string;
NOTIFICATIONS_URL: string;
NODE_ENV: string;
CLOUDFLARE_TOKEN: string;
diff --git a/src/types/drive.ts b/src/types/drive.ts
index 000cce940..b2a957fb9 100644
--- a/src/types/drive.ts
+++ b/src/types/drive.ts
@@ -1,3 +1,4 @@
+import { DocumentPickerResponse } from 'react-native-document-picker';
import { SharedFiles, SharedFolders } from '@internxt/sdk/dist/drive/share/types';
import {
DriveFileData,
@@ -10,6 +11,15 @@ import {
const GB = 1024 * 1024 * 1024;
export const UPLOAD_FILE_SIZE_LIMIT = 5 * GB;
+/**
+ * Extended DocumentPickerResponse with file metadata timestamps.
+ * Used during file upload process to preserve original creation and modification times.
+ */
+export type DocumentPickerFile = DocumentPickerResponse & {
+ modificationTime?: string;
+ creationTime?: string;
+};
+
export interface DriveNavigationStackItem {
id: number;
parentId: number;
@@ -43,6 +53,7 @@ export type DriveItemFocused = {
isFolder: boolean;
bucket?: string;
uuid?: string;
+ thumbnails?: Thumbnail[];
} | null;
export interface GetModifiedFiles {
@@ -119,6 +130,8 @@ export interface UploadingFile {
size: number;
progress: number;
uploaded: boolean;
+ modificationTime?: string;
+ creationTime?: string;
}
export interface DownloadingFile {
diff --git a/src/types/index.ts b/src/types/index.ts
index b1b0c0043..56b8fed8b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -99,6 +99,7 @@ export enum AsyncStorageKey {
LastSecurityCheck = 'lastSecurityCheck',
SecurityAlertDismissed = 'securityAlertDismissed',
LastSecurityHash = 'lastSecurityHash',
+ ScreenProtectionEnabled = 'screenProtectionEnabled',
}
export type ProgressCallback = (progress: number) => void;
diff --git a/src/types/navigation.ts b/src/types/navigation.ts
index 7f4699f83..2ea96fcbf 100644
--- a/src/types/navigation.ts
+++ b/src/types/navigation.ts
@@ -14,6 +14,12 @@ export type RootStackParamList = {
Debug: undefined;
SignUp: undefined;
SignIn: undefined;
+ WebLogin: {
+ mnemonic?: string;
+ token?: string;
+ newToken?: string;
+ privateKey?: string;
+ } | undefined;
DeactivatedAccount: undefined;
TabExplorer: NavigatorScreenParams;
ForgotPassword: undefined;
diff --git a/src/useCases/drive/trash.ts b/src/useCases/drive/trash.ts
index 0b1004272..76835d20c 100644
--- a/src/useCases/drive/trash.ts
+++ b/src/useCases/drive/trash.ts
@@ -181,7 +181,7 @@ export const clearTrash = async (): Promise> => {
* Moves items to trash
*/
export const moveItemsToTrash = async (
- items: { id: string; type: 'file' | 'folder'; dbItemId: number }[],
+ items: { id: string; type: 'file' | 'folder'; dbItemId: number; uuid?: string }[],
onUndo: () => void,
): Promise> => {
try {
diff --git a/tsconfig.json b/tsconfig.json
index c54b8f53e..b657dd5ed 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,7 +9,8 @@
"@internxt-mobile/hooks/*": ["src/hooks/*"],
"@internxt-mobile/services/*": ["src/services/*"],
"@internxt-mobile/types/*": ["src/types/*"],
- "@internxt-mobile/useCases/*": ["src/useCases/*"]
+ "@internxt-mobile/useCases/*": ["src/useCases/*"],
+ "@internxt-mobile/contexts/*": ["src/contexts/*"]
}
},
"include": ["app.config.ts", "src", "__tests__", "../modile-sdk"]
diff --git a/yarn.lock b/yarn.lock
index f43569c3e..aba68fd40 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1766,10 +1766,10 @@
dependencies:
buffer "^6.0.3"
-"@internxt/sdk@1.11.0":
- version "1.11.0"
- resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.0/eb547ca5a15d4e56a4e23db065482b3533e11b33#eb547ca5a15d4e56a4e23db065482b3533e11b33"
- integrity sha512-65v2zzpACKlBXBoQURVrfevyELNBB1NypNiOqhlR4havwnCLrLgX9ZMSoUKvBtq2O0Orfdfqy8AA2AJrq+xW3Q==
+"@internxt/sdk@=1.11.6":
+ version "1.11.6"
+ resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.11.6/af41e231f6aa28df84977f96ef0049c295446907#af41e231f6aa28df84977f96ef0049c295446907"
+ integrity sha512-H/cd1e574Z1Adcb0jrQp+LIIUPXEkJZEExrxV2m3JgFMc6+VIlJTDzzTi5Bbz42ycSEF+vareY24Qfcn6ijlEA==
dependencies:
axios "1.11.0"
uuid "11.1.0"
@@ -3095,11 +3095,6 @@
redux-thunk "^2.4.2"
reselect "^4.1.8"
-"@rudderstack/rudder-sdk-react-native@^1.5.1":
- version "1.14.1"
- resolved "https://registry.yarnpkg.com/@rudderstack/rudder-sdk-react-native/-/rudder-sdk-react-native-1.14.1.tgz#ff58c532ad9ec37f5f79cfbf103eb0b97e3ed16d"
- integrity sha512-Jgao//Ff/UN6KalKGYzlgSApqf9apVuj4OLcsMeCYkHd3L/tqqMI0LiFVU3mX4Z5BV10+mC62ZWIlLSvRo6HHA==
-
"@segment/loosely-validate-event@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@segment/loosely-validate-event/-/loosely-validate-event-2.0.0.tgz#87dfc979e5b4e7b82c5f1d8b722dfd5d77644681"
@@ -5878,9 +5873,9 @@ electron-to-chromium@^1.5.28:
integrity sha512-HYTX8tKge/VNp6FGO+f/uVDmUkq+cEfcxYhKf15Akc4M5yxt5YmorwlAitKWjWhWQnKcDRBAQKXkhqqXMqcrjw==
elliptic@^6.5.3, elliptic@^6.5.5:
- version "6.5.7"
- resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.7.tgz#8ec4da2cb2939926a1b9a73619d768207e647c8b"
- integrity sha512-ESVCtTwiA+XhY3wyh24QqRGBoP3rEdDUl3EDUUo9tft074fi19IrdpH7hLCMMP3CIj7jb3W96rn8lt/BqIlt5Q==
+ version "6.6.1"
+ resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.6.1.tgz#3b8ffb02670bf69e382c7f65bf524c97c5405c06"
+ integrity sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==
dependencies:
bn.js "^4.11.9"
brorand "^1.1.0"