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"