now deep linking completed and loader also added for the app

This commit is contained in:
yashwin-foxy 2025-10-03 18:17:10 +05:30
parent 1145517757
commit 95159446f9
33 changed files with 5072 additions and 166 deletions

362
App.tsx
View File

@ -1,131 +1,269 @@
/** import React, { JSX, useEffect, useState } from 'react';
* Sample React Native App import { SafeAreaView, StyleSheet, Platform, StatusBar, AppState, View, Text, ActivityIndicator, Animated } from 'react-native';
* https://github.com/facebook/react-native import { WebView } from 'react-native-webview';
*
* @format
*/
import React from 'react'; // Utility imports
import type {PropsWithChildren} from 'react';
import { import {
ScrollView, createNotificationChannel,
StatusBar, configureNotificationSettings,
StyleSheet, testNotification
Text, } from './utilities/notificationUtils';
useColorScheme,
View,
} from 'react-native';
import { import {
Colors, createDeepLinkHandler,
DebugInstructions, NotificationData
Header, } from './utilities/deepLinkUtils';
LearnMoreLinks, import {
ReloadInstructions, createWebViewHandler
} from 'react-native/Libraries/NewAppScreen'; } from './utilities/webViewUtils';
import {
createCookieHandler
} from './utilities/cookieUtils';
import {
createFCMHandler,
NotificationCallbacks
} from './utilities/fcmUtils';
import { ALLOWED_DOMAIN } from './utilities/constants';
import { CustomLoader, createOdooLoader, createNavigationLoader, createChatLoader } from './utilities/loaderUtils';
type SectionProps = PropsWithChildren<{ // Suppress Firebase deprecation warnings (temporary until Firebase v22+ is stable)
title: string; const originalWarn = console.warn;
}>; console.warn = (...args) => {
if (args[0] && typeof args[0] === 'string' &&
function Section({children, title}: SectionProps): React.JSX.Element { args[0].includes('This method is deprecated') &&
const isDarkMode = useColorScheme() === 'dark'; args[0].includes('Firebase')) {
return ( return; // Suppress only Firebase deprecation warnings
<View style={styles.sectionContainer}>
<Text
style={[
styles.sectionTitle,
{
color: isDarkMode ? Colors.white : Colors.black,
},
]}>
{title}
</Text>
<Text
style={[
styles.sectionDescription,
{
color: isDarkMode ? Colors.light : Colors.dark,
},
]}>
{children}
</Text>
</View>
);
} }
originalWarn.apply(console, args);
};
// @ts-ignore
import PushNotification from 'react-native-push-notification';
function App(): React.JSX.Element { /**
const isDarkMode = useColorScheme() === 'dark'; * Main App Component
* Handles FCM notifications, WebView management, and deep linking
*/
export default function App(): JSX.Element {
const backgroundStyle = { // Track app state to avoid duplicate notifications
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter, const [appState, setAppState] = React.useState(AppState.currentState);
// WebView reference for accessing cookies
const webViewRef = React.useRef<WebView>(null);
// Deep linking state
const [currentChannel, setCurrentChannel] = React.useState<string | null>(null);
const webViewUrlRef = React.useRef(`${ALLOWED_DOMAIN}/web`);
const [webViewKey, setWebViewKey] = React.useState(0); // Force re-render when URL changes
const [isNavigating, setIsNavigating] = React.useState(false);
const [navigationAttempts, setNavigationAttempts] = React.useState(0);
// State to store cookies
const [webViewCookies, setWebViewCookies] = React.useState<string | null>(null);
// Loading state
const [isLoading, setIsLoading] = useState(true);
const [loadingText, setLoadingText] = useState('Connecting to T4B...');
const [loaderType, setLoaderType] = useState<'odoo' | 'navigation' | 'chat'>('odoo');
// Helper function to update WebView URL immediately (without loader)
const updateWebViewUrl = (newUrl: string) => {
console.log('🔗 Updating WebView URL immediately:', newUrl);
// Don't show loader for navigation to avoid flickering
webViewUrlRef.current = newUrl;
setWebViewKey(prev => prev + 1); // Force WebView re-render
console.log('✅ WebView URL updated:', newUrl);
}; };
/* // Create utility handlers
* To keep the template simple and small we're adding padding to prevent view const cookieHandler = createCookieHandler(setWebViewCookies);
* from rendering under the System UI. const webViewHandler = createWebViewHandler(webViewRef, updateWebViewUrl, {
* For bigger apps the recommendation is to use `react-native-safe-area-context`: currentChannel,
* https://github.com/AppAndFlow/react-native-safe-area-context isNavigating
* });
* You can read more about it here:
* https://github.com/react-native-community/discussions-and-proposals/discussions/827 // Navigation state for deep link handler
*/ const navigationState = {
const safePadding = '5%'; currentChannel,
isNavigating,
navigationAttempts
};
const deepLinkHandler = createDeepLinkHandler(
updateWebViewUrl,
setIsNavigating,
setCurrentChannel,
setNavigationAttempts,
navigationState
);
// FCM notification callbacks
const fcmCallbacks: NotificationCallbacks = {
onForegroundNotification: (data: NotificationData) => {
deepLinkHandler.handleForegroundBackgroundDeepLink(data);
},
onBackgroundNotification: (data: NotificationData) => {
deepLinkHandler.handleForegroundBackgroundDeepLink(data);
},
onKilledAppNotification: (data: NotificationData) => {
deepLinkHandler.handleKilledAppDeepLink(data);
}
};
const fcmHandler = createFCMHandler(
() => cookieHandler.getWebViewCookies(),
fcmCallbacks
);
// Handle app state changes
useEffect(() => {
const handleAppStateChange = (nextAppState: any) => {
console.log('App state changed:', appState, '->', nextAppState);
setAppState(nextAppState);
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => subscription?.remove();
}, [appState]);
// Initialize FCM and WebView
useEffect(() => {
// Configure push notifications
createNotificationChannel();
configureNotificationSettings();
// Initialize FCM notifications
fcmHandler.initializeNotifications();
// Start periodic cookie refresh
const cleanupCookieRefresh = cookieHandler.refreshWebViewCookies();
// Setup FCM message listeners
const cleanupFCMListeners = fcmHandler.setupMessageListeners(appState);
// Setup test functions for debugging
setupTestFunctions();
return () => {
cleanupCookieRefresh();
cleanupFCMListeners();
};
}, []);
// Setup test functions for debugging
const setupTestFunctions = () => {
if (typeof global !== 'undefined') {
(global as any).testNotification = testNotification;
(global as any).refreshCookies = () => {
console.log('🔄 Manual cookie refresh triggered');
cookieHandler.getWebViewCookies();
};
(global as any).checkCookies = () => {
console.log('🔍 Current cookie state:', webViewCookies);
console.log('🔍 WebView ref available:', !!webViewRef.current);
cookieHandler.getWebViewCookies();
};
(global as any).testDeepLink = (channelId: string) => {
console.log('🔗 Testing deep link for channel:', channelId);
deepLinkHandler.navigateToChannel(channelId);
};
(global as any).testDeepLinkUrl = (deepLink: string) => {
console.log('🔗 Testing deep link URL:', deepLink);
deepLinkHandler.handleDeepLink(deepLink);
};
(global as any).simulateNotification = (channelId: string) => {
console.log('🔗 Simulating notification tap for channel:', channelId);
setTimeout(() => {
deepLinkHandler.handleForegroundBackgroundDeepLink({ channel_id: channelId });
}, 100);
};
(global as any).testWebViewUrlUpdate = (channelId: string) => {
console.log('🔗 Testing WebView URL update for channel:', channelId);
deepLinkHandler.handleForegroundBackgroundDeepLink({ channel_id: channelId });
};
(global as any).debugWebView = () => {
webViewHandler.injectDebugScript();
};
(global as any).navigateToChannel = (channelId: string) => {
console.log('🔗 Direct navigation to channel:', channelId);
deepLinkHandler.navigateToChannel(channelId);
};
(global as any).updateWebViewUrl = (url: string) => {
console.log('🔗 Direct WebView URL update:', url);
updateWebViewUrl(url);
};
console.log('🧪 Test functions available:');
console.log(' - global.testNotification()');
console.log(' - global.refreshCookies()');
console.log(' - global.checkCookies()');
console.log(' - global.testDeepLink(channelId) - For killed app deep linking');
console.log(' - global.testWebViewUrlUpdate(channelId) - For foreground/background URL update');
console.log(' - global.testDeepLinkUrl("myapp://chat/123")');
console.log(' - global.simulateNotification(channelId)');
console.log(' - global.debugWebView() - Debug WebView state and elements');
console.log(' - global.navigateToChannel(channelId) - Direct navigation using WebView URL update');
console.log(' - global.updateWebViewUrl(url) - Immediate WebView URL update');
}
};
// Handle WebView navigation
const handleNavigation = (request: any): boolean => {
return webViewHandler.handleNavigation(request);
};
// Handle WebView messages
const handleWebViewMessage = (event: any) => {
webViewHandler.handleWebViewMessage(event, setWebViewCookies);
};
return ( return (
<View style={backgroundStyle}> <SafeAreaView style={styles.container}>
<StatusBar <WebView
barStyle={isDarkMode ? 'light-content' : 'dark-content'} ref={webViewRef}
backgroundColor={backgroundStyle.backgroundColor} source={{ uri: webViewUrlRef.current }}
style={styles.webview}
key={webViewKey}
javaScriptEnabled
domStorageEnabled
startInLoadingState={false} // Use custom loader instead
originWhitelist={['*']}
onShouldStartLoadWithRequest={handleNavigation}
onMessage={handleWebViewMessage}
onLoadStart={() => {
console.log('🔄 WebView started loading');
// Don't show loader during WebView navigation to avoid flickering
}}
onLoadEnd={() => {
console.log('✅ WebView finished loading');
setIsLoading(false); // Only hide initial loader
webViewHandler.handleWebViewLoadEnd(currentChannel, isNavigating);
}}
onError={(error) => {
console.error('❌ WebView error:', error);
setIsLoading(false);
setLoadingText('Connection error');
}}
//@ts-ignore
onConsoleMessage={(event: any) => {
console.log('🌐 WebView Console:', event.nativeEvent.message);
}}
/> />
<ScrollView
style={backgroundStyle}> {/* Dynamic Beautiful Custom Loader */}
<View style={{paddingRight: safePadding}}> {loaderType === 'odoo' && createOdooLoader(isLoading, loadingText)}
<Header/> {loaderType === 'navigation' && createNavigationLoader(isLoading, loadingText)}
</View> {loaderType === 'chat' && createChatLoader(isLoading, loadingText)}
<View </SafeAreaView>
style={{
backgroundColor: isDarkMode ? Colors.black : Colors.white,
paddingHorizontal: safePadding,
paddingBottom: safePadding,
}}>
<Section title="Step One">
Edit <Text style={styles.highlight}>App.tsx</Text> to change this
screen and then come back to see your edits.
</Section>
<Section title="See Your Changes">
<ReloadInstructions />
</Section>
<Section title="Debug">
<DebugInstructions />
</Section>
<Section title="Learn More">
Read the docs to discover what to do next:
</Section>
<LearnMoreLinks />
</View>
</ScrollView>
</View>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
sectionContainer: { container: {
marginTop: 32, flex: 1,
paddingHorizontal: 24, // paddingTop: Platform.OS === 'android' ? StatusBar.currentHeight ?? 0 : 0,
}, },
sectionTitle: { webview: {
fontSize: 24, flex: 1,
fontWeight: '600',
},
sectionDescription: {
marginTop: 8,
fontSize: 18,
fontWeight: '400',
},
highlight: {
fontWeight: '700',
}, },
}); });
export default App;

View File

@ -0,0 +1,384 @@
# FCM Notification Module Implementation Guide
This document contains all the changes made to implement Firebase Cloud Messaging (FCM) notifications in the T4B_Chat React Native app. Use this guide to replicate the implementation in your production project.
## 📦 Package Dependencies Added
### package.json Dependencies
```json
{
"dependencies": {
"@react-native-firebase/app": "^23.4.0",
"@react-native-firebase/messaging": "^23.4.0",
"@react-native-cookies/cookies": "^6.2.0",
"react-native-device-info": "^10.11.0",
"react-native-permissions": "^5.2.4",
"react-native-push-notification": "^8.1.1"
}
}
```
### Installation Commands
```bash
npm install @react-native-firebase/app @react-native-firebase/messaging
npm install @react-native-cookies/cookies
npm install react-native-device-info
npm install react-native-permissions
npm install react-native-push-notification
```
## 🔧 Android Configuration
### 1. Root build.gradle (android/build.gradle)
```gradle
buildscript {
ext {
buildToolsVersion = "35.0.0"
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
ndkVersion = "27.1.12297006"
kotlinVersion = "2.0.21"
}
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
classpath("com.google.gms:google-services:4.3.15") // ← ADD THIS
}
}
```
### 2. App build.gradle (android/app/build.gradle)
```gradle
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
apply plugin: 'com.google.gms.google-services' // ← ADD THIS
android {
// ... existing config ...
// Fix for react-native-push-notification duplicate classes
configurations.all {
exclude group: 'com.android.support', module: 'support-compat'
exclude group: 'com.android.support', module: 'support-annotations'
}
defaultConfig {
applicationId "com.t4b_chat"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
}
}
dependencies {
implementation("com.facebook.react:react-android")
// AndroidX dependencies for react-native-push-notification compatibility
implementation 'androidx.core:core:1.16.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
// ... rest of dependencies ...
}
```
### 3. AndroidManifest.xml (android/app/src/main/AndroidManifest.xml)
```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <!-- ← ADD THIS -->
<uses-permission android:name="android.permission.WAKE_LOCK" /> <!-- ← ADD THIS -->
<uses-permission android:name="android.permission.VIBRATE" /> <!-- ← ADD THIS -->
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:supportsRtl="true">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FCM Service --> <!-- ← ADD THIS ENTIRE SERVICE BLOCK -->
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>
```
### 4. Google Services Configuration
- Add `google-services.json` file to `android/app/` directory
- This file comes from your Firebase project console
## 📱 iOS Configuration
### 1. Podfile (ios/Podfile)
```ruby
# Resolve react_native_pods.rb with node to allow for hoisting
require Pod::Executable.execute_command('node', ['-p',
'require.resolve(
"react-native/scripts/react_native_pods.rb",
{paths: [process.argv[1]]},
)', __dir__]).strip
platform :ios, min_ios_version_supported
prepare_react_native_project!
linkage = ENV['USE_FRAMEWORKS']
if linkage != nil
Pod::UI.puts "Configuring Pod with #{linkage}ally linked Frameworks".green
use_frameworks! :linkage => linkage.to_sym
end
target 'T4B_Chat' do
config = use_native_modules!
use_react_native!(
:path => config[:reactNativePath],
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
)
end
end
```
### 2. iOS Google Services Configuration
- Add `GoogleService-Info.plist` to your iOS project
- This file comes from your Firebase project console
## 📁 New Utility Files Created
### 1. utilities/permissionUtils.ts
Complete permission management utility for handling notification permissions across platforms.
Key functions:
- `needsNotificationPermission()` - Check if device needs explicit permission
- `requestNotificationPermission()` - Request notification permission
- `checkNotificationPermission()` - Check current permission status
- `showPermissionExplanation()` - Show user-friendly explanation dialog
- `showSettingsDialog()` - Direct user to app settings
### 2. utilities/notificationUtils.ts
Complete notification management utility for handling local notifications.
Key functions:
- `createNotificationChannel()` - Create Android notification channel
- `configureNotificationSettings()` - Configure push notification settings
- `showLocalNotification()` - Display local notifications
- `shouldShowCustomNotification()` - Determine when to show notifications
- `testNotification()` - Test notification function
- `cancelAllNotifications()` - Cancel all pending notifications
## 🔄 App.tsx Changes
### Key Imports Added
```typescript
import messaging, { FirebaseMessagingTypes, AuthorizationStatus } from '@react-native-firebase/messaging';
import { getApp } from '@react-native-firebase/app';
import {
requestNotificationPermission,
checkNotificationPermission,
needsNotificationPermission,
showPermissionExplanation,
showSettingsDialog,
PermissionResult
} from './utilities/permissionUtils';
import { RESULTS } from 'react-native-permissions';
import {
showLocalNotification,
createNotificationChannel,
configureNotificationSettings,
shouldShowCustomNotification,
testNotification
} from './utilities/notificationUtils';
import PushNotification from 'react-native-push-notification';
```
### Key Features Implemented
1. **App State Tracking**
- Tracks app state changes to avoid duplicate notifications
- Uses `AppState.addEventListener` for state monitoring
2. **FCM Message Handling**
- `onMessage` listener for foreground messages
- `getInitialNotification` for app launch from notification
- `onNotificationOpenedApp` for background notification taps
3. **Permission Management**
- Comprehensive permission checking and requesting
- Platform-specific permission handling (Android 13+ vs older versions)
- User-friendly permission explanation dialogs
4. **Token Management**
- FCM token retrieval and refresh handling
- Token sending to backend (commented out - needs implementation)
5. **Notification Display**
- Custom notification display when app is active
- Fallback to system notifications when app is backgrounded
- Test notification functionality for debugging
6. **Firebase Warning Suppression**
- Temporary suppression of Firebase deprecation warnings
## 🚀 Setup Instructions for Production
### 1. Install Dependencies
```bash
npm install @react-native-firebase/app @react-native-firebase/messaging
npm install react-native-permissions
npm install react-native-push-notification
```
### 2. Firebase Project Setup
1. Create Firebase project at https://console.firebase.google.com
2. Add Android app with package name matching your `applicationId`
3. Add iOS app with bundle identifier
4. Download configuration files:
- `google-services.json` for Android
- `GoogleService-Info.plist` for iOS
### 3. Android Setup
1. Add Google Services plugin to build.gradle files
2. Add permissions to AndroidManifest.xml
3. Add FCM service to AndroidManifest.xml
4. Add AndroidX dependencies for compatibility
### 4. iOS Setup
1. Add GoogleService-Info.plist to Xcode project
2. Run `cd ios && pod install`
3. Enable Push Notifications capability in Xcode
### 5. Code Integration
1. Copy both utility files to your project
2. Integrate App.tsx changes
3. Update package.json dependencies
4. Test notification functionality
## 🧪 Testing
### Test Notification Function
The implementation includes a test notification function accessible via:
- URL navigation to `test-notification` endpoint
- Global function `global.testNotification()` in development
- Console logging for debugging
### Debug Features
- Comprehensive console logging throughout the notification flow
- Permission status logging
- FCM token logging
- App state change logging
## 🔗 Backend Integration
### FCM Token Registration
The app automatically sends FCM tokens to your Odoo backend using the following endpoint:
**Endpoint**: `POST https://your-odoo.com/fcm/register`
**Headers**:
```json
{
"Content-Type": "application/json",
"Cookie": "session_id=YOUR_SESSION_COOKIE"
}
```
**Request Body**:
```json
{
"token": "FCM_TOKEN_STRING",
"device_name": "iPhone 12",
"device_type": "ios",
"app_version": "1.0.0",
"os_version": "15.0"
}
```
### Device Information Collection
The implementation automatically collects device information using `react-native-device-info`:
- `device_name`: Device model name (e.g., "iPhone 12", "Samsung Galaxy S21")
- `device_type`: Platform ("ios" or "android")
- `app_version`: App version from package.json
- `os_version`: Operating system version
### Session Management
**Native Cookie Extraction**: The implementation uses `@react-native-cookies/cookies` to automatically extract session cookies from the native cookie store. The `getWebViewCookies()` function:
1. **Accesses native cookie store** using `@react-native-cookies/cookies` library
2. **Retrieves all cookies** for the Odoo domain
3. **Filters and extracts session_id** from available cookies
4. **Updates automatically** every 30 seconds to keep cookies fresh
5. **Validates cookies** before sending FCM token registration
The system automatically handles session management without manual intervention and is much more reliable than JavaScript injection methods.
### Token Refresh Handling
The app automatically handles FCM token refresh and sends updated tokens to the backend whenever they change.
## ⚠️ Important Notes
1. **Backend Integration**: The `sendTokenToBackend()` function is implemented and automatically sends FCM tokens to your Odoo backend at `/fcm/register` endpoint.
2. **Session Management**: The implementation automatically extracts session cookies from the WebView, so no manual session management is required.
3. **Device Information**: The implementation uses `react-native-device-info` to automatically collect device information (device name, type, app version, OS version).
4. **Firebase Warnings**: Temporary warning suppression is in place until Firebase v22+ is stable.
5. **Permission Handling**: The implementation handles Android 13+ permission requirements automatically.
6. **Production Considerations**:
- Update signing configuration for release builds
- Test on both Android and iOS devices
- Verify notification delivery in all app states
- Ensure WebView loads and user can log in to extract session cookies
## 📋 Checklist for Production Migration
- [ ] Install all required npm packages
- [ ] Add Google Services configuration files
- [ ] Update Android build.gradle files
- [ ] Update AndroidManifest.xml with permissions and FCM service
- [ ] Copy utility files (permissionUtils.ts, notificationUtils.ts)
- [ ] Integrate App.tsx changes
- [ ] Test on Android device (API 33+ for permission testing)
- [ ] Test on iOS device
- [ ] Implement backend token registration
- [ ] Test notification delivery in all app states
- [ ] Remove debug/test code before production release
This implementation provides a complete FCM notification system with proper permission handling, platform-specific optimizations, and comprehensive error handling.

View File

@ -1,6 +1,7 @@
apply plugin: "com.android.application" apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android" apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react" apply plugin: "com.facebook.react"
apply plugin: 'com.google.gms.google-services'
/** /**
* This is the configuration block to customize your React Native Android app. * This is the configuration block to customize your React Native Android app.
@ -58,6 +59,7 @@ react {
* Set this to true to Run Proguard on Release builds to minify the Java bytecode. * Set this to true to Run Proguard on Release builds to minify the Java bytecode.
*/ */
def enableProguardInReleaseBuilds = false def enableProguardInReleaseBuilds = false
def enableSeparateBuildPerCPUArchitecture = true
/** /**
* The preferred build flavor of JavaScriptCore (JSC) * The preferred build flavor of JavaScriptCore (JSC)
@ -78,6 +80,19 @@ android {
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
namespace "com.t4b_chat" namespace "com.t4b_chat"
// Fix for react-native-push-notification duplicate classes
configurations.all {
exclude group: 'com.android.support', module: 'support-compat'
exclude group: 'com.android.support', module: 'support-annotations'
}
splits {
abi {
enable true
include 'armeabi-v7a', 'arm64-v8a', 'x86'
universalApk false
}
}
defaultConfig { defaultConfig {
applicationId "com.t4b_chat" applicationId "com.t4b_chat"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
@ -111,9 +126,15 @@ dependencies {
// The version of react-native is set by the React Native Gradle Plugin // The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android") implementation("com.facebook.react:react-android")
// AndroidX dependencies for react-native-push-notification compatibility
implementation 'androidx.core:core:1.16.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.11.0'
if (hermesEnabled.toBoolean()) { if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android") implementation("com.facebook.react:hermes-android")
} else { } else {
implementation jscFlavor implementation jscFlavor
} }
} }
apply from: file("../../node_modules/react-native-vector-icons/fonts.gradle")

View File

@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "640541795037",
"project_id": "t4b-chat",
"storage_bucket": "t4b-chat.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:640541795037:android:b40cb90983466cdafd3134",
"android_client_info": {
"package_name": "com.t4b_chat"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyCQ1zgB0uVL3VgRnsPrbdYQOneOJzc2AAM"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<application <application
android:name=".MainApplication" android:name=".MainApplication"
@ -9,6 +12,8 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false" android:allowBackup="false"
android:theme="@style/AppTheme" android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"> android:supportsRtl="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@ -22,5 +27,14 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<!-- FCM Service -->
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnPrimary">
<path
android:fillColor="@android:color/white"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
</vector>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -4,6 +4,7 @@
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar"> <style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<!-- Customize your theme here. --> <!-- Customize your theme here. -->
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item> <item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style> </style>
</resources> </resources>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.1.12</domain>
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>

View File

@ -15,6 +15,7 @@ buildscript {
classpath("com.android.tools.build:gradle") classpath("com.android.tools.build:gradle")
classpath("com.facebook.react:react-native-gradle-plugin") classpath("com.facebook.react:react-native-gradle-plugin")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin")
classpath("com.google.gms:google-services:4.3.15")
} }
} }

View File

@ -21,6 +21,8 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# Android operating system, and which are packaged with your app's APK # Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn # https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Use this property to specify which architecture you want to build. # Use this property to specify which architecture you want to build.
# You can also override it from the CLI using # You can also override it from the CLI using
@ -32,7 +34,7 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# your application. You should enable this flag either if you want # your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that # to write custom TurboModules/Fabric components OR use libraries that
# are providing them. # are providing them.
newArchEnabled=true newArchEnabled=false
# Use this property to enable or disable the Hermes JS engine. # Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead. # If set to false, you will be using JSC instead.

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

2285
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,36 @@
"test": "jest" "test": "jest"
}, },
"dependencies": { "dependencies": {
"@react-native-async-storage/async-storage": "^2.2.0",
"@react-native-community/netinfo": "^11.4.1",
"@react-native-cookies/cookies": "^6.2.1",
"@react-native-firebase/app": "^23.4.0",
"@react-native-firebase/messaging": "^23.4.0",
"@react-native-vector-icons/common": "^12.3.0",
"@react-navigation/bottom-tabs": "^7.4.7",
"@react-navigation/native": "^7.1.17",
"@react-navigation/native-stack": "^7.3.26",
"@react-navigation/stack": "^7.4.8",
"@reduxjs/toolkit": "^2.9.0",
"apisauce": "^3.2.0",
"react": "19.0.0", "react": "19.0.0",
"react-native": "0.79.0" "react-native": "0.79.0",
"react-native-device-info": "^10.3.0",
"react-native-element-dropdown": "^2.12.4",
"react-native-gesture-handler": "^2.28.0",
"react-native-linear-gradient": "^2.8.3",
"react-native-permissions": "^5.2.4",
"react-native-push-notification": "^8.1.1",
"react-native-raw-bottom-sheet": "^3.0.0",
"react-native-reanimated": "^3.19.1",
"react-native-safe-area-context": "^5.6.1",
"react-native-screens": "^4.16.0",
"react-native-svg": "^15.12.1",
"react-native-toast-message": "^2.2.1",
"react-native-vector-icons": "^10.3.0",
"react-native-webview": "^13.16.0",
"react-redux": "^9.2.0",
"redux-persist": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "@babel/core": "^7.25.2",

134
utilities/README.md Normal file
View File

@ -0,0 +1,134 @@
# Utilities
This directory contains organized utility functions and modules for the T4B Chat application.
## Files
### notificationUtils.ts
Contains utility functions for creating notification channels, configuring settings, and showing local notifications.
### permissionUtils.ts
Handles notification permission requests and checks for Android and iOS platforms.
### deepLinkUtils.ts
Handles deep linking functionality for FCM notifications and chat navigation.
- `DeepLinkHandler` class for managing deep links
- Methods for handling killed app, foreground, and background deep links
- Channel ID extraction and URL building utilities
### webViewUtils.ts
Manages WebView functionality, navigation, and message handling.
- `WebViewHandler` class for WebView management
- Navigation request handling and validation
- Message processing and debug script injection
### cookieUtils.ts
Handles cookie management for WebView authentication.
- `CookieHandler` class for cookie operations
- Platform-specific cookie retrieval (iOS/Android)
- Session cookie validation and refresh functionality
### fcmUtils.ts
Manages Firebase Cloud Messaging (FCM) functionality.
- `FCMHandler` class for FCM operations
- Token management and backend communication
- Message handling for different app states
### constants.ts
Centralized configuration constants for the entire application.
- Domain configuration
- FCM endpoints
- Navigation delays
- URL patterns
- Platform configuration
- Notification settings
### loaderUtils.tsx
Beautiful custom loading components with animations and chat icons.
- `CustomLoader` - Main animated loader component
- `ProgressLoader` - Loader with progress indicator
- Predefined loader configurations for different scenarios
- Smooth animations and transitions
## Usage
Import the utilities you need in your components:
```typescript
import { showLocalNotification, createNotificationChannel } from './utilities/notificationUtils';
import { requestNotificationPermission, checkNotificationPermission } from './utilities/permissionUtils';
import { createDeepLinkHandler } from './utilities/deepLinkUtils';
import { createWebViewHandler } from './utilities/webViewUtils';
import { createCookieHandler } from './utilities/cookieUtils';
import { createFCMHandler } from './utilities/fcmUtils';
import { ALLOWED_DOMAIN, NAVIGATION_DELAYS, URL_PATTERNS } from './utilities/constants';
import { CustomLoader, createOdooLoader, createChatLoader } from './utilities/loaderUtils';
```
## Classes and Methods
### Deep Link Handler
- `handleKilledAppDeepLink(data)` - Handle deep links from killed app
- `handleForegroundBackgroundDeepLink(data)` - Handle deep links from foreground/background
- `navigateToChannel(channelId)` - Navigate to specific channel
- `handleDeepLink(deepLink)` - Process deep link URLs
### WebView Handler
- `handleNavigation(request)` - Handle WebView navigation requests
- `handleWebViewMessage(event, setWebViewCookies)` - Process WebView messages
- `handleWebViewLoadEnd(currentChannel, isNavigating)` - Handle load completion
- `injectDebugScript()` - Inject debug script into WebView
### Cookie Handler
- `getWebViewCookies()` - Get authentication cookies
- `refreshWebViewCookies()` - Set up periodic cookie refresh
- `getCookiesForUrl(url)` - Get cookies for specific URL
- `setCookiesForUrl(url, cookies)` - Set cookies for specific URL
- `clearAllCookies()` - Clear all cookies
- `isValidSessionCookie(cookies)` - Validate session cookies
### FCM Handler
- `initializeNotifications()` - Initialize FCM notifications
- `requestUserPermission()` - Request notification permissions
- `getFcmToken()` - Get FCM token
- `sendTokenToBackend(token)` - Send token to backend
- `setupMessageListeners(appState)` - Setup FCM message listeners
### Constants
- `ALLOWED_DOMAIN` - Base domain for the application
- `NAVIGATION_DELAYS` - Timing constants for navigation delays
- `URL_PATTERNS` - URL pattern templates
- `FCM_REGISTER_ENDPOINT` - FCM registration endpoint
- `COOKIE_REFRESH_INTERVAL` - Cookie refresh timing
- `NOTIFICATION_CONFIG` - Notification channel configuration
### Loader Components
- `CustomLoader` - Main animated loader with chat icon
- `ProgressLoader` - Loader with progress indicator
- `createOdooLoader()` - Pre-configured Odoo loader
- `createChatLoader()` - Pre-configured chat loader
- `createNavigationLoader()` - Pre-configured navigation loader
- `LOADER_CONFIGS` - Predefined loader configurations
### Notification Utils
- `createNotificationChannel()` - Creates Android notification channel
- `configureNotificationSettings()` - Configures notification settings
- `showLocalNotification(options)` - Shows local notification
- `shouldShowCustomNotification(appState)` - Checks if custom notification should be shown
- `testNotification()` - Test notification function
### Permission Utils
- `requestNotificationPermission()` - Requests notification permission
- `checkNotificationPermission()` - Checks current permission status
- `needsNotificationPermission()` - Checks if permission is needed
- `showPermissionExplanation()` - Shows permission explanation dialog
- `showSettingsDialog()` - Shows settings dialog for denied permissions
## Architecture Benefits
1. **Separation of Concerns**: Each utility handles a specific domain
2. **Reusability**: Utilities can be easily reused across components
3. **Maintainability**: Code is organized and easy to understand
4. **Testability**: Each utility can be tested independently
5. **Type Safety**: Proper TypeScript interfaces and types
6. **Documentation**: Comprehensive JSDoc comments throughout

45
utilities/constants.ts Normal file
View File

@ -0,0 +1,45 @@
/**
* Common Constants
* Centralized configuration constants for the T4B Chat application
*/
// Domain Configuration
export const ALLOWED_DOMAIN = 'http://192.168.1.12:8069';
// FCM Configuration
export const FCM_REGISTER_ENDPOINT = '/fcm/register';
// Cookie Configuration
export const COOKIE_REFRESH_INTERVAL = 30000; // 30 seconds
export const INITIAL_COOKIE_DELAY = 10000; // 10 seconds
// Navigation Configuration
export const NAVIGATION_DELAYS = {
FOREGROUND: 100, // Minimal delay for foreground notifications
BACKGROUND: 200, // Minimal delay for background notifications
KILLED_APP: 100, // Minimal delay for killed app notifications
TWO_STEP_NAVIGATION: 300, // Delay between two-step navigation
NAVIGATION_RESET: 500, // Delay before resetting navigation state
KILLED_APP_RESET: 800, // Delay before resetting killed app navigation state
} as const;
// URL Patterns
export const URL_PATTERNS = {
MAIL_APP: '#action=106&cids=1&menu_id=73',
CHAT_CHANNEL: (channelId: string) => `&active_id=mail.channel_${channelId}`,
} as const;
// Deep Link Configuration
export const DEEP_LINK_PREFIX = 'myapp://chat/';
// Platform Configuration
export const PLATFORM_CONFIG = {
ANDROID_MIN_VERSION_FOR_PERMISSIONS: 33, // Android 13+
} as const;
// Notification Configuration
export const NOTIFICATION_CONFIG = {
CHANNEL_ID: 't4b_chat_channel',
CHANNEL_NAME: 'T4B Chat Notifications',
CHANNEL_DESCRIPTION: 'Notifications for T4B Chat messages',
} as const;

247
utilities/cookieUtils.ts Normal file
View File

@ -0,0 +1,247 @@
/**
* Cookie Utilities
* Handles cookie management for WebView authentication
*/
import CookieManager from '@react-native-cookies/cookies';
import { Platform } from 'react-native';
import { ALLOWED_DOMAIN, COOKIE_REFRESH_INTERVAL, INITIAL_COOKIE_DELAY } from './constants';
/**
* Interface for cookie data
*/
export interface CookieData {
[key: string]: any;
}
/**
* Cookie Handler Class
*/
export class CookieHandler {
private setWebViewCookies: (cookies: string | null) => void;
constructor(setWebViewCookies: (cookies: string | null) => void) {
this.setWebViewCookies = setWebViewCookies;
}
/**
* Get WebView cookies for authentication
* @returns Promise<string | null> - Cookie string or null if not found
*/
async getWebViewCookies(): Promise<string | null> {
try {
console.log('🍪 Requesting cookies using @react-native-cookies/cookies...');
const baseUrl = `${ALLOWED_DOMAIN}`;
console.log('🍪 Getting cookies for URL:', baseUrl);
let cookieString = '';
// Platform-specific cookie handling
if (Platform.OS === 'ios') {
// iOS: Can use getAll()
try {
const cookies = await CookieManager.getAll();
console.log('🍪 iOS - All cookies retrieved:', cookies);
// Filter cookies for our domain
const domainCookies = Object.keys(cookies).filter(key =>
key.includes(baseUrl) || key.includes(ALLOWED_DOMAIN.replace('/', ''))
);
console.log('🍪 iOS - Domain cookies found:', domainCookies);
for (const cookieKey of domainCookies) {
const cookie = cookies[cookieKey];
console.log('🍪 iOS - Checking cookie:', cookieKey, cookie);
if (cookie && typeof cookie === 'object') {
const cookieValue = cookie.value || cookie;
if (cookieValue && typeof cookieValue === 'string' && cookieValue.includes('session_id=')) {
const sessionMatch = cookieValue.match(/session_id=([^;]+)/);
if (sessionMatch) {
console.log('✅ iOS - Found session_id in cookie:', sessionMatch[1]);
}
}
if (typeof cookie === 'object' && cookie.name && cookie.value) {
cookieString += `${cookie.name}=${cookie.value}; `;
} else if (typeof cookie === 'string') {
cookieString += cookie + '; ';
}
}
}
} catch (iosError) {
console.log('⚠️ iOS getAll() failed, trying direct method:', iosError);
}
}
// Both platforms: Try getting cookies directly for the domain
try {
console.log('🍪 Getting direct cookies for domain:', baseUrl);
const directCookies = await CookieManager.get(baseUrl);
console.log('🍪 Direct cookies for domain:', directCookies);
if (directCookies && Object.keys(directCookies).length > 0) {
// Convert to cookie string format
const directCookieString = Object.entries(directCookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
console.log('🍪 Direct cookie string:', directCookieString);
if (directCookieString && directCookieString.includes('session_id')) {
cookieString = directCookieString;
console.log('✅ Found session_id in direct cookies:', cookieString);
} else {
console.warn('⚠️ Direct cookies found but no session_id:', directCookieString);
}
} else {
console.warn('⚠️ No direct cookies found for domain');
}
} catch (directError) {
console.log('⚠️ Could not get direct cookies:', directError);
}
// Fallback: Try getting cookies for the WebView URL
try {
const webViewUrl = `${ALLOWED_DOMAIN}/web`;
console.log('🍪 Fallback - Getting cookies for WebView URL:', webViewUrl);
const webViewCookies = await CookieManager.get(webViewUrl);
console.log('🍪 WebView cookies:', webViewCookies);
if (webViewCookies && Object.keys(webViewCookies).length > 0) {
const webViewCookieString = Object.entries(webViewCookies)
.map(([name, value]) => `${name}=${value}`)
.join('; ');
if (webViewCookieString && webViewCookieString.includes('session_id')) {
cookieString = webViewCookieString;
console.log('✅ Found session_id in WebView cookies:', cookieString);
}
}
} catch (webViewError) {
console.log('⚠️ Could not get WebView cookies:', webViewError);
}
if (cookieString && cookieString.includes('session_id')) {
console.log('✅ Valid session cookies found:', cookieString);
this.setWebViewCookies(cookieString);
return cookieString;
} else {
console.warn('⚠️ No session_id found in any cookie method');
console.warn('⚠️ Final cookie string:', cookieString);
this.setWebViewCookies(null);
return null;
}
} catch (error) {
console.error('❌ Error getting cookies with @react-native-cookies/cookies:', error);
this.setWebViewCookies(null);
return null;
}
}
/**
* Refresh WebView cookies periodically
* @returns Function to clear the interval
*/
refreshWebViewCookies(): () => void {
// Initial cookie request after a longer delay to ensure WebView is fully loaded and user is logged in
setTimeout(() => {
console.log('🔄 Initial cookie refresh after WebView load');
this.getWebViewCookies();
}, INITIAL_COOKIE_DELAY);
// Set up periodic cookie refresh
const cookieRefreshInterval = setInterval(() => {
console.log('🔄 Periodic cookie refresh');
this.getWebViewCookies();
}, COOKIE_REFRESH_INTERVAL);
// Return cleanup function
return () => clearInterval(cookieRefreshInterval);
}
/**
* Get cookies for a specific URL
* @param url - URL to get cookies for
* @returns Promise<CookieData | null> - Cookie data or null
*/
async getCookiesForUrl(url: string): Promise<CookieData | null> {
try {
console.log('🍪 Getting cookies for URL:', url);
const cookies = await CookieManager.get(url);
console.log('🍪 Cookies for URL:', cookies);
return cookies;
} catch (error) {
console.error('❌ Error getting cookies for URL:', url, error);
return null;
}
}
/**
* Set cookies for a specific URL
* @param url - URL to set cookies for
* @param cookies - Cookie data to set
* @returns Promise<boolean> - Success status
*/
async setCookiesForUrl(url: string, cookies: any): Promise<boolean> {
try {
console.log('🍪 Setting cookies for URL:', url, cookies);
await CookieManager.set(url, cookies);
console.log('✅ Cookies set successfully for URL:', url);
return true;
} catch (error) {
console.error('❌ Error setting cookies for URL:', url, error);
return false;
}
}
/**
* Clear all cookies
* @returns Promise<boolean> - Success status
*/
async clearAllCookies(): Promise<boolean> {
try {
console.log('🍪 Clearing all cookies');
await CookieManager.clearAll();
console.log('✅ All cookies cleared successfully');
this.setWebViewCookies(null);
return true;
} catch (error) {
console.error('❌ Error clearing cookies:', error);
return false;
}
}
/**
* Check if session cookies are valid
* @param cookies - Cookie string to validate
* @returns boolean - Whether cookies are valid
*/
isValidSessionCookie(cookies: string | null): boolean {
if (!cookies) {
return false;
}
const hasSessionId = cookies.includes('session_id=');
const hasValidFormat = cookies.includes('=') && cookies.includes(';');
console.log('🍪 Cookie validation:', {
hasSessionId,
hasValidFormat,
cookieLength: cookies.length
});
return hasSessionId && hasValidFormat;
}
}
/**
* Create cookie handler instance
*/
export function createCookieHandler(
setWebViewCookies: (cookies: string | null) => void
): CookieHandler {
return new CookieHandler(setWebViewCookies);
}

242
utilities/deepLinkUtils.ts Normal file
View File

@ -0,0 +1,242 @@
/**
* Deep Link Utilities
* Handles deep linking functionality for FCM notifications and chat navigation
*/
import { Platform } from 'react-native';
import {
ALLOWED_DOMAIN,
NAVIGATION_DELAYS,
URL_PATTERNS,
DEEP_LINK_PREFIX
} from './constants';
/**
* Interface for notification data
*/
export interface NotificationData {
channel_id?: string;
conversation_id?: string;
deep_link?: string;
title?: string;
body?: string;
channel_name?: string;
author_name?: string;
notification_type?: string;
}
/**
* Interface for navigation state
*/
export interface NavigationState {
currentChannel: string | null;
isNavigating: boolean;
navigationAttempts: number;
}
/**
* Deep Link Handler Class
*/
export class DeepLinkHandler {
private updateWebViewUrl: (url: string) => void;
private setIsNavigating: (navigating: boolean) => void;
private setCurrentChannel: (channel: string | null) => void;
private setNavigationAttempts: (attempts: number) => void;
private navigationState: NavigationState;
constructor(
updateWebViewUrl: (url: string) => void,
setIsNavigating: (navigating: boolean) => void,
setCurrentChannel: (channel: string | null) => void,
setNavigationAttempts: (attempts: number) => void,
navigationState: NavigationState
) {
this.updateWebViewUrl = updateWebViewUrl;
this.setIsNavigating = setIsNavigating;
this.setCurrentChannel = setCurrentChannel;
this.setNavigationAttempts = setNavigationAttempts;
this.navigationState = navigationState;
}
/**
* Handle deep linking from notifications (killed app case)
* @param data - Notification data containing channel information
*/
handleKilledAppDeepLink(data: NotificationData): void {
console.log('🔗 Processing deep link from notification (killed app case):', data);
const channelId = this.extractChannelId(data);
if (!channelId) {
console.warn('⚠️ No channel ID found in deep link data:', data);
return;
}
// Minimal delay for killed app initialization
setTimeout(() => {
console.log('🔗 Setting initial WebView URL for killed app navigation to channel:', channelId);
// Set navigation state
this.setIsNavigating(true);
this.setCurrentChannel(channelId);
// Construct and set the target URL directly (works better for killed app)
const chatUrl = this.buildChatUrl(channelId);
console.log('🔗 Initial chat URL for killed app:', chatUrl);
this.updateWebViewUrl(chatUrl);
// Reset navigation state after shorter delay
setTimeout(() => {
this.setIsNavigating(false);
}, NAVIGATION_DELAYS.KILLED_APP_RESET);
}, NAVIGATION_DELAYS.KILLED_APP);
}
/**
* Handle WebView URL update from notification (foreground/background)
* @param data - Notification data containing channel information
*/
handleForegroundBackgroundDeepLink(data: NotificationData): void {
console.log('🔗 Updating WebView URL from notification:', data);
const channelId = this.extractChannelId(data);
if (!channelId) {
console.warn('⚠️ No channel ID found in notification data:', data);
return;
}
// Check if we're already navigating to this channel
if (this.navigationState.isNavigating && this.navigationState.currentChannel === channelId) {
console.log('🔗 Already navigating to channel:', channelId);
return;
}
console.log('🔗 Navigating to channel via WebView URL update:', channelId);
// Set navigation state to prevent loops
this.setIsNavigating(true);
this.setNavigationAttempts(0);
this.setCurrentChannel(channelId);
// Step 1: First navigate to mail app (without specific channel)
const mailAppUrl = `${ALLOWED_DOMAIN}/web${URL_PATTERNS.MAIL_APP}`;
console.log('🔗 Step 1: Navigating to mail app:', mailAppUrl);
this.updateWebViewUrl(mailAppUrl);
// Step 2: After a minimal delay, navigate to specific channel
setTimeout(() => {
const chatUrl = this.buildChatUrl(channelId);
console.log('🔗 Step 2: Navigating to specific channel:', chatUrl);
this.updateWebViewUrl(chatUrl);
// Reset navigation state after final navigation
setTimeout(() => {
this.setIsNavigating(false);
}, NAVIGATION_DELAYS.NAVIGATION_RESET);
}, NAVIGATION_DELAYS.TWO_STEP_NAVIGATION);
console.log('✅ WebView navigation sequence started');
}
/**
* Navigate to specific channel (direct navigation for killed app)
* @param channelId - Channel ID to navigate to
*/
navigateToChannel(channelId: string): void {
if (!channelId) {
console.warn('⚠️ No channel ID provided for navigation');
return;
}
// Check if we're already navigating to this channel
if (this.navigationState.isNavigating && this.navigationState.currentChannel === channelId) {
console.log('🔗 Already navigating to channel:', channelId);
return;
}
console.log('🔗 Navigating to channel (killed app - direct navigation):', channelId);
this.setCurrentChannel(channelId);
this.setIsNavigating(true);
this.setNavigationAttempts(0);
// For killed app case, direct navigation works better
const chatUrl = this.buildChatUrl(channelId);
console.log('🔗 Chat URL (direct):', chatUrl);
// Update WebView URL directly - this works well for killed app
this.updateWebViewUrl(chatUrl);
console.log('✅ WebView URL updated (direct navigation)');
// Reset navigation state after delay
setTimeout(() => {
this.setIsNavigating(false);
}, NAVIGATION_DELAYS.KILLED_APP);
}
/**
* Handle deep link URLs
* @param deepLink - Deep link URL to process
*/
handleDeepLink(deepLink: string): void {
console.log('🔗 Processing deep link:', deepLink);
// Parse deep link: myapp://chat/{channel_id}
if (deepLink.startsWith(DEEP_LINK_PREFIX)) {
const channelId = deepLink.replace(DEEP_LINK_PREFIX, '');
console.log('🔗 Extracted channel ID:', channelId);
this.navigateToChannel(channelId);
} else {
console.warn('⚠️ Unknown deep link format:', deepLink);
}
}
/**
* Extract channel ID from notification data
* @param data - Notification data
* @returns Channel ID or null if not found
*/
private extractChannelId(data: NotificationData): string | null {
// Extract channel ID from different possible fields
if (data.channel_id) {
console.log('🔗 Found channel_id:', data.channel_id);
return data.channel_id;
} else if (data.conversation_id) {
console.log('🔗 Found conversation_id:', data.conversation_id);
return data.conversation_id;
} else if (data.deep_link && data.deep_link.includes('chat/')) {
const channelId = data.deep_link.replace(DEEP_LINK_PREFIX, '');
console.log('🔗 Extracted channel_id from deep_link:', channelId);
return channelId;
}
return null;
}
/**
* Build chat URL for specific channel
* @param channelId - Channel ID
* @returns Complete chat URL
*/
private buildChatUrl(channelId: string): string {
return `${ALLOWED_DOMAIN}/web${URL_PATTERNS.MAIL_APP}${URL_PATTERNS.CHAT_CHANNEL(channelId)}`;
}
}
/**
* Create deep link handler instance
*/
export function createDeepLinkHandler(
updateWebViewUrl: (url: string) => void,
setIsNavigating: (navigating: boolean) => void,
setCurrentChannel: (channel: string | null) => void,
setNavigationAttempts: (attempts: number) => void,
navigationState: NavigationState
): DeepLinkHandler {
return new DeepLinkHandler(
updateWebViewUrl,
setIsNavigating,
setCurrentChannel,
setNavigationAttempts,
navigationState
);
}

357
utilities/fcmUtils.ts Normal file
View File

@ -0,0 +1,357 @@
/**
* FCM (Firebase Cloud Messaging) Utilities
* Handles FCM token management, messaging, and backend communication
*/
import messaging, { FirebaseMessagingTypes } from '@react-native-firebase/messaging';
import { Platform } from 'react-native';
import {
requestNotificationPermission,
checkNotificationPermission,
needsNotificationPermission,
showPermissionExplanation,
showSettingsDialog,
PermissionResult
} from './permissionUtils';
import { RESULTS } from 'react-native-permissions';
import { showLocalNotification } from './notificationUtils';
import { ALLOWED_DOMAIN, FCM_REGISTER_ENDPOINT, NAVIGATION_DELAYS } from './constants';
/**
* Interface for FCM token data
*/
export interface FCMTokenData {
token: string;
deviceName: string;
deviceType: string;
appVersion: string;
osVersion: string;
sessionCookies: string | null;
}
/**
* Interface for notification callback functions
*/
export interface NotificationCallbacks {
onForegroundNotification: (data: any) => void;
onBackgroundNotification: (data: any) => void;
onKilledAppNotification: (data: any) => void;
}
/**
* FCM Handler Class
*/
export class FCMHandler {
private getWebViewCookies: () => Promise<string | null>;
private callbacks: NotificationCallbacks;
constructor(
getWebViewCookies: () => Promise<string | null>,
callbacks: NotificationCallbacks
) {
this.getWebViewCookies = getWebViewCookies;
this.callbacks = callbacks;
}
/**
* Initialize FCM notifications
*/
async initializeNotifications(): Promise<void> {
try {
console.log('Initializing notifications...');
// Request permission first
await this.requestUserPermission();
// Then get FCM token
await this.getFcmToken();
console.log('Notification initialization completed');
} catch (error) {
console.error('Error initializing notifications:', error);
}
}
/**
* Request user permission for notifications
*/
async requestUserPermission(): Promise<void> {
try {
console.log('Checking notification permission requirements...');
// Check if permission is needed based on platform and version
const needsPermission = needsNotificationPermission();
console.log('Permission needed:', needsPermission);
if (!needsPermission) {
console.log('No explicit permission required for this Android version');
return;
}
// Check current permission status first
const currentStatus = await checkNotificationPermission();
console.log('Current permission status:', currentStatus);
if (currentStatus.granted) {
console.log('Notification permission already granted');
return;
}
// Show explanation dialog for Android users
if (Platform.OS === 'android') {
const userWantsPermission = await showPermissionExplanation();
if (!userWantsPermission) {
console.log('User declined permission request');
return;
}
}
// Request permission
const result: PermissionResult = await requestNotificationPermission();
console.log('📋 Permission request result:', JSON.stringify(result, null, 2));
if (result.granted) {
console.log('✅ Notification permission granted successfully');
} else {
console.log('❌ Notification permission denied');
console.log('📋 Denial details:', {
status: result.status,
platform: result.platform,
androidVersion: result.androidVersion
});
// Check for different denial scenarios
if (Platform.OS === 'android') {
if (result.status === RESULTS.DENIED) {
console.log('🚫 Permission denied by user');
showSettingsDialog();
} else if (result.status === RESULTS.BLOCKED) {
console.log('🚫 Permission blocked by user');
showSettingsDialog();
} else if (result.status === RESULTS.UNAVAILABLE) {
console.log('⚠️ Permission not available on this device');
} else {
console.log('❓ Unknown permission status:', result.status);
}
}
}
} catch (error) {
console.error('Error requesting permission:', error);
}
}
/**
* Get FCM token
*/
async getFcmToken(): Promise<void> {
try {
console.log('🔑 Attempting to get FCM token...');
// Check if we have permission before trying to get token
const permissionStatus = await checkNotificationPermission();
console.log('🔍 Permission status for token:', JSON.stringify(permissionStatus, null, 2));
if (!permissionStatus.granted && needsNotificationPermission()) {
console.log('❌ Cannot get FCM token: notification permission not granted');
return;
}
console.log('✅ Permission check passed, requesting FCM token...');
const token = await messaging().getToken();
console.log('🎫 FCM Token received:', token ? 'YES' : 'NO');
if (token) {
console.log('📤 FCM Token:', token);
// Send FCM token to Odoo backend
await this.sendTokenToBackend(token);
} else {
console.log('⚠️ No FCM token available');
}
} catch (err) {
console.error('❌ Error getting FCM token:', err);
}
}
/**
* Send FCM token to backend
* @param token - FCM token to send
*/
async sendTokenToBackend(token: string): Promise<void> {
try {
// Import react-native-device-info
const DeviceInfo = require('react-native-device-info');
// Get device information
const deviceName = await DeviceInfo.getDeviceName();
const deviceType = Platform.OS;
const appVersion = await DeviceInfo.getVersion();
const osVersion = await DeviceInfo.getSystemVersion();
// Get session cookies from WebView
const sessionCookies = await this.getWebViewCookies();
console.log('📤 Sending FCM token to backend:', {
token: token ? token.substring(0, 20) + '...' : 'NO',
deviceName,
deviceType,
appVersion,
osVersion,
hasSessionCookies: !!sessionCookies
});
if (!sessionCookies) {
console.warn('⚠️ No session cookies found - user may not be logged in');
return;
}
const response = await fetch(`${ALLOWED_DOMAIN}${FCM_REGISTER_ENDPOINT}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookies,
},
body: JSON.stringify({
token: token,
device_name: deviceName,
device_type: deviceType,
app_version: appVersion,
os_version: osVersion,
}),
});
if (response.ok) {
const result = await response.json();
console.log('✅ FCM token registered successfully:', result);
} else {
console.error('❌ Failed to register FCM token:', response.status, response.statusText);
}
} catch (err) {
console.error('❌ Error sending token to backend:', err);
}
}
/**
* Handle FCM messages when app is in foreground
* @param remoteMessage - FCM message received
* @param appState - Current app state
*/
handleForegroundMessage(
remoteMessage: FirebaseMessagingTypes.RemoteMessage,
appState: string
): void {
console.log('📨 FCM Message received:', JSON.stringify(remoteMessage, null, 2));
console.log('📱 Current app state:', appState);
// Always show notification in foreground - FCM doesn't show notifications automatically in foreground
console.log('📤 Showing foreground notification');
if (remoteMessage.notification) {
console.log('📤 Showing notification with payload');
showLocalNotification({
title: remoteMessage.notification.title || 'New Message',
message: remoteMessage.notification.body || 'You have a new message',
});
// For foreground: Update WebView URL directly based on notification data
if (remoteMessage.data) {
console.log('🔗 Processing notification data for WebView URL update (foreground):', remoteMessage.data);
setTimeout(() => {
this.callbacks.onForegroundNotification(remoteMessage.data);
}, NAVIGATION_DELAYS.FOREGROUND);
}
} else if (remoteMessage.data) {
console.log('📤 Showing notification with data only');
// Extract notification info from data payload
const title = String(remoteMessage.data.title || 'Odoo Chat');
const body = String(remoteMessage.data.body || 'New message received');
showLocalNotification({
title: title,
message: body,
});
// For foreground: Update WebView URL directly based on notification data
console.log('🔗 Processing data payload for WebView URL update (foreground):', remoteMessage.data);
setTimeout(() => {
this.callbacks.onForegroundNotification(remoteMessage.data);
}, NAVIGATION_DELAYS.FOREGROUND);
} else {
console.log('⚠️ No notification content available');
}
}
/**
* Handle FCM messages when app is opened from background
* @param remoteMessage - FCM message received
*/
handleBackgroundMessage(remoteMessage: FirebaseMessagingTypes.RemoteMessage): void {
if (remoteMessage?.data) {
console.log('🔗 App opened from background with notification (using WebView URL update):', remoteMessage.data);
// For background state: Update WebView URL directly
setTimeout(() => {
this.callbacks.onBackgroundNotification(remoteMessage.data);
}, NAVIGATION_DELAYS.BACKGROUND);
}
}
/**
* Handle FCM messages when app is opened from quit state
* @param remoteMessage - FCM message received
*/
handleKilledAppMessage(remoteMessage: FirebaseMessagingTypes.RemoteMessage): void {
if (remoteMessage?.data) {
console.log('🔗 App opened from quit state with notification (using deep linking):', remoteMessage.data);
// For quit state: Use proper deep linking approach
setTimeout(() => {
this.callbacks.onKilledAppNotification(remoteMessage.data);
}, NAVIGATION_DELAYS.KILLED_APP);
}
}
/**
* Setup FCM message listeners
* @param appState - Current app state
* @returns Function to unsubscribe from listeners
*/
setupMessageListeners(appState: string): () => void {
// Handle FCM messages when app is in foreground
const unsubscribe = messaging().onMessage(async (remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
this.handleForegroundMessage(remoteMessage, appState);
});
// Handle background/quit state notifications with deep linking
messaging()
.getInitialNotification()
.then((remoteMessage: FirebaseMessagingTypes.RemoteMessage | null) => {
if (remoteMessage) {
this.handleKilledAppMessage(remoteMessage);
}
});
messaging().onNotificationOpenedApp((remoteMessage: FirebaseMessagingTypes.RemoteMessage) => {
this.handleBackgroundMessage(remoteMessage);
});
// Listen for token refresh
const unsubscribeTokenRefresh = messaging().onTokenRefresh(async (token) => {
console.log('🔄 FCM Token refreshed:', token);
await this.sendTokenToBackend(token);
});
return () => {
unsubscribe();
unsubscribeTokenRefresh();
};
}
}
/**
* Create FCM handler instance
*/
export function createFCMHandler(
getWebViewCookies: () => Promise<string | null>,
callbacks: NotificationCallbacks
): FCMHandler {
return new FCMHandler(getWebViewCookies, callbacks);
}

387
utilities/loaderUtils.tsx Normal file
View File

@ -0,0 +1,387 @@
/**
* Loader Utilities
* Beautiful custom loading component with chat icon and animations
*/
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
ActivityIndicator,
Animated,
StyleSheet,
Dimensions
} from 'react-native';
//@ts-ignore
import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
// Get screen dimensions
const { width, height } = Dimensions.get('window');
/**
* Interface for loader props
*/
export interface LoaderProps {
isLoading: boolean;
loadingText?: string;
showChatIcon?: boolean;
iconName?: string;
iconSize?: number;
backgroundColor?: string;
primaryColor?: string;
textColor?: string;
}
/**
* Beautiful Custom Loader Component
*/
export const CustomLoader: React.FC<LoaderProps> = ({
isLoading,
loadingText = 'Loading...',
showChatIcon = true,
iconName = 'chat-processing',
iconSize = 40,
backgroundColor = 'rgba(255, 255, 255, 1)', // Pure white
primaryColor = '#007AFF',
textColor = '#333333'
}) => {
// Animation values
const fadeAnim = useRef(new Animated.Value(0)).current;
const scaleAnim = useRef(new Animated.Value(0.8)).current;
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (isLoading) {
// Instant animations for better UX during navigation
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 0,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 0,
useNativeDriver: true,
}),
]).start();
// Pulse animation with reduced intensity
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.05,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
])
);
pulseAnimation.start();
return () => {
pulseAnimation.stop();
};
} else {
// Instant hide animations for better UX during navigation
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 0.8,
duration: 0,
useNativeDriver: true,
}),
]).start();
}
}, [isLoading]);
if (!isLoading) return null;
return (
<Animated.View
style={[
styles.loaderContainer,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }]
}
]}
>
<View style={styles.loaderContent}>
{/* Chat Icon */}
{showChatIcon && (
<Animated.View
style={[
styles.chatIconContainer,
{
backgroundColor: primaryColor,
transform: [
{ scale: pulseAnim }
]
}
]}
>
<Icon name={iconName} size={iconSize} color="white" />
</Animated.View>
)}
{/* Loading Spinner */}
<View style={styles.spinnerContainer}>
<ActivityIndicator
size="large"
color={primaryColor}
style={styles.spinner}
/>
</View>
{/* Loading Text */}
<Animated.Text
style={[
styles.loadingText,
{
color: textColor,
opacity: fadeAnim
}
]}
>
{loadingText}
</Animated.Text>
{/* Loading Dots Animation */}
<View style={styles.dotsContainer}>
{[0, 1, 2].map((index) => (
<Animated.View
key={index}
style={[
styles.dot,
{
backgroundColor: primaryColor,
opacity: pulseAnim,
transform: [
{
scale: pulseAnim.interpolate({
inputRange: [1, 1.1],
outputRange: [1, 1.2],
})
}
]
}
]}
/>
))}
</View>
</View>
</Animated.View>
);
};
/**
* Enhanced Loader with Progress
*/
export const ProgressLoader: React.FC<LoaderProps & { progress?: number }> = ({
isLoading,
loadingText = 'Loading...',
progress = 0,
...props
}) => {
const progressAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
Animated.timing(progressAnim, {
toValue: progress,
duration: 300,
useNativeDriver: false,
}).start();
}, [progress]);
return (
<CustomLoader
isLoading={isLoading}
loadingText={`${loadingText} ${Math.round(progress * 100)}%`}
{...props}
/>
);
};
/**
* Create loader styles
*/
const styles = StyleSheet.create({
loaderContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 1)', // Pure white background
},
loaderContent: {
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 40,
paddingVertical: 30,
borderRadius: 20,
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.15,
shadowRadius: 4.65,
elevation: 8,
backgroundColor: 'rgba(255, 255, 255, 1)',
borderWidth: 1,
borderColor: 'rgba(0, 0, 0, 0.05)',
},
chatIconContainer: {
width: 80,
height: 80,
borderRadius: 40,
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
shadowColor: '#007AFF',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
spinnerContainer: {
marginBottom: 20,
},
spinner: {
transform: [{ scale: 1.2 }],
},
loadingText: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
marginBottom: 15,
letterSpacing: 0.5,
},
dotsContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
marginHorizontal: 4,
},
});
/**
* Predefined loader configurations with vector icons
*/
export const LOADER_CONFIGS = {
CHAT: {
loadingText: 'Connecting to chat...',
showChatIcon: true,
iconName: 'chat-processing' as const,
iconSize: 40,
primaryColor: '#007AFF',
},
ODOO: {
loadingText: 'Loading T4B...',
showChatIcon: true,
iconName: 'chat-processing' as const,
iconSize: 40,
primaryColor: '#875A7B',
},
NAVIGATION: {
loadingText: 'Navigating...',
showChatIcon: true,
iconName: 'navigation' as const,
iconSize: 40,
primaryColor: '#28A745',
},
MESSAGE: {
loadingText: 'Sending message...',
showChatIcon: true,
iconName: 'message-text' as const,
iconSize: 40,
primaryColor: '#FF6B35',
},
SYNC: {
loadingText: 'Synchronizing...',
showChatIcon: true,
iconName: 'sync' as const,
iconSize: 40,
primaryColor: '#9C27B0',
},
DEFAULT: {
loadingText: 'Loading...',
showChatIcon: true,
iconName: 'loading' as const,
iconSize: 40,
primaryColor: '#007AFF',
},
} as const;
/**
* Quick loader functions with vector icons
*/
export const createChatLoader = (isLoading: boolean, text?: string) => (
<CustomLoader
isLoading={isLoading}
{...LOADER_CONFIGS.CHAT}
loadingText={text || LOADER_CONFIGS.CHAT.loadingText}
/>
);
export const createOdooLoader = (isLoading: boolean, text?: string) => (
<CustomLoader
isLoading={isLoading}
{...LOADER_CONFIGS.ODOO}
loadingText={text || LOADER_CONFIGS.ODOO.loadingText}
/>
);
export const createNavigationLoader = (isLoading: boolean, text?: string) => (
<CustomLoader
isLoading={isLoading}
{...LOADER_CONFIGS.NAVIGATION}
loadingText={text || LOADER_CONFIGS.NAVIGATION.loadingText}
/>
);
export const createMessageLoader = (isLoading: boolean, text?: string) => (
<CustomLoader
isLoading={isLoading}
{...LOADER_CONFIGS.MESSAGE}
loadingText={text || LOADER_CONFIGS.MESSAGE.loadingText}
/>
);
export const createSyncLoader = (isLoading: boolean, text?: string) => (
<CustomLoader
isLoading={isLoading}
{...LOADER_CONFIGS.SYNC}
loadingText={text || LOADER_CONFIGS.SYNC.loadingText}
/>
);
export const createDefaultLoader = (isLoading: boolean, text?: string) => (
<CustomLoader
isLoading={isLoading}
{...LOADER_CONFIGS.DEFAULT}
loadingText={text || LOADER_CONFIGS.DEFAULT.loadingText}
/>
);

View File

@ -0,0 +1,152 @@
// @ts-ignore
import PushNotification from 'react-native-push-notification';
import { AppState, AppStateStatus } from 'react-native';
/**
* Creates a notification channel for Android
*/
export const createNotificationChannel = (): void => {
PushNotification.createChannel(
{
channelId: 'default-channel-id',
channelName: 'Default Channel',
channelDescription: 'A default channel for notifications',
playSound: true,
soundName: 'default',
importance: 4, // High importance
vibrate: true,
},
(created: boolean) => console.log(`Notification channel created: ${created}`)
);
};
/**
* Configures notification settings
*/
export const configureNotificationSettings = (): void => {
PushNotification.configure({
// (optional) Called when Token is generated (iOS and Android)
onRegister: function (token: any) {
console.log('TOKEN:', token);
},
// (required) Called when a remote is received or opened, or local notification is opened
onNotification: function (notification: any) {
console.log('NOTIFICATION:', notification);
},
// (optional) Called when Registered Action is pressed and invokeApp is false, if true onNotification will be called (Android)
onAction: function (notification: any) {
console.log('ACTION:', notification.action);
console.log('NOTIFICATION:', notification);
},
// (optional) Called when the user fails to register for remote notifications. Typically occurs when APNS is having issues, or the device is a simulator. (iOS)
onRegistrationError: function (err: any) {
console.error(err.message, err);
},
// IOS ONLY (optional): default: all - Permissions to register.
permissions: {
alert: true,
badge: true,
sound: true,
},
// Should the initial notification be popped automatically
// default: true
popInitialNotification: true,
/**
* (optional) default: true
* - Specified if permissions (ios) and token (android and ios) will requested or not,
* - if not, you must call PushNotificationsHandler.requestPermissions() later
* - if you are not using remote notification or do not have Firebase installed, use this:
* requestPermissions: Platform.OS === 'ios'
*/
requestPermissions: true,
});
};
/**
* Shows a local notification
*/
export const showLocalNotification = (notificationData: {
title: string;
message: string;
channelId?: string;
playSound?: boolean;
soundName?: string;
vibrate?: boolean;
priority?: 'min' | 'low' | 'default' | 'high' | 'max';
visibility?: 'private' | 'public' | 'secret';
}): void => {
PushNotification.localNotification({
channelId: notificationData.channelId || 'default-channel-id',
title: notificationData.title,
message: notificationData.message,
playSound: notificationData.playSound !== false,
soundName: notificationData.soundName || 'default',
vibrate: notificationData.vibrate !== false,
priority: notificationData.priority || 'high',
visibility: notificationData.visibility || 'private',
importance: 'high',
});
};
/**
* Determines if a custom notification should be shown based on app state
*/
export const shouldShowCustomNotification = (appState: AppStateStatus): boolean => {
// Show notification when app is in background or inactive
return appState !== 'active';
};
/**
* Test function to trigger a sample notification
*/
export const testNotification = (): void => {
showLocalNotification({
title: 'Test Notification',
message: 'This is a test notification from T4B Chat app',
channelId: 'default-channel-id',
playSound: true,
vibrate: true,
});
};
/**
* Cancels all notifications
*/
export const cancelAllNotifications = (): void => {
PushNotification.cancelAllLocalNotifications();
};
/**
* Cancels a specific notification by ID
*/
export const cancelNotification = (notificationId: string): void => {
PushNotification.cancelLocalNotifications({ id: notificationId });
};
/**
* Gets the notification count
*/
export const getNotificationCount = (callback: (count: number) => void): void => {
PushNotification.getApplicationIconBadgeNumber(callback);
};
/**
* Sets the notification count
*/
export const setNotificationCount = (count: number): void => {
PushNotification.setApplicationIconBadgeNumber(count);
};
/**
* Clears all notifications and badge count
*/
export const clearAllNotifications = (): void => {
cancelAllNotifications();
setNotificationCount(0);
};

View File

@ -0,0 +1,279 @@
import { Platform, Alert } from 'react-native';
import messaging, { AuthorizationStatus } from '@react-native-firebase/messaging';
import { request, PERMISSIONS, RESULTS, Permission } from 'react-native-permissions';
export interface PermissionResult {
granted: boolean;
status: string;
needsRequest: boolean;
platform: string;
androidVersion?: number;
}
/**
* Check if the device requires explicit notification permission request
* Android 13+ (API 33+) requires POST_NOTIFICATIONS permission
* Older Android versions don't need explicit permission for notifications
*/
export function needsNotificationPermission(): boolean {
if (Platform.OS === 'ios') {
return true; // iOS always requires permission
}
if (Platform.OS === 'android') {
// Android 13+ (API level 33+) requires POST_NOTIFICATIONS permission
return Platform.Version >= 33;
}
return false;
}
/**
* Get the appropriate notification permission constant based on platform
*/
export function getNotificationPermission(): Permission | null {
if (Platform.OS === 'android') {
//@ts-ignore
return 'android.permission.POST_NOTIFICATIONS';
}
// iOS uses Firebase messaging permission, not react-native-permissions
return null;
}
/**
* Request notification permission for Android 13+
* Returns permission status information
*/
export async function requestNotificationPermission(): Promise<PermissionResult> {
if (Platform.OS === 'ios') {
// iOS uses Firebase messaging permission
try {
const authStatus = await messaging().requestPermission();
const granted = authStatus === AuthorizationStatus.AUTHORIZED ||
authStatus === AuthorizationStatus.PROVISIONAL;
return {
granted,
status: AuthorizationStatus[authStatus],
needsRequest: true,
platform: 'ios'
};
} catch (error) {
return {
granted: false,
status: 'ERROR',
needsRequest: true,
platform: 'ios'
};
}
}
if (Platform.OS === 'android') {
const androidVersion = Platform.Version as number;
const permission = getNotificationPermission();
if (!permission) {
// Older Android versions don't need explicit permission
return {
granted: true,
status: 'GRANTED',
needsRequest: false,
platform: 'android',
androidVersion
};
}
try {
const result = await request(permission);
return {
granted: result === RESULTS.GRANTED,
status: result,
needsRequest: true,
platform: 'android',
androidVersion
};
} catch (error) {
return {
granted: false,
status: 'ERROR',
needsRequest: true,
platform: 'android',
androidVersion
};
}
}
return {
granted: false,
status: 'UNSUPPORTED',
needsRequest: false,
platform: 'unknown'
};
}
/**
* Check current notification permission status
*/
export async function checkNotificationPermission(): Promise<PermissionResult> {
if (Platform.OS === 'ios') {
try {
const authStatus = await messaging().hasPermission();
const granted = authStatus === AuthorizationStatus.AUTHORIZED ||
authStatus === AuthorizationStatus.PROVISIONAL;
return {
granted,
status: AuthorizationStatus[authStatus],
needsRequest: !granted,
platform: 'ios'
};
} catch (error) {
return {
granted: false,
status: 'ERROR',
needsRequest: true,
platform: 'ios'
};
}
}
if (Platform.OS === 'android') {
const androidVersion = Platform.Version as number;
const permission = getNotificationPermission();
if (!permission) {
// Older Android versions - assume granted
return {
granted: true,
status: 'GRANTED',
needsRequest: false,
platform: 'android',
androidVersion
};
}
try {
const { check } = require('react-native-permissions');
const result = await check(permission);
return {
granted: result === RESULTS.GRANTED,
status: result,
needsRequest: result !== RESULTS.GRANTED,
platform: 'android',
androidVersion
};
} catch (error) {
return {
granted: false,
status: 'ERROR',
needsRequest: true,
platform: 'android',
androidVersion
};
}
}
return {
granted: false,
status: 'UNSUPPORTED',
needsRequest: false,
platform: 'unknown'
};
}
/**
* Show permission explanation dialog for Android
*/
export function showPermissionExplanation(): Promise<boolean> {
return new Promise((resolve) => {
Alert.alert(
'Notification Permission',
'This app needs notification permission to send you chat messages and updates. You can change this later in your device settings.',
[
{
text: 'Cancel',
style: 'cancel',
onPress: () => resolve(false),
},
{
text: 'Allow',
onPress: () => resolve(true),
},
]
);
});
}
/**
* Show settings redirect dialog when permission is denied
*/
export function showSettingsDialog(): void {
Alert.alert(
'Permission Required',
'Notification permission is required for this app to work properly. Please enable it in your device settings.',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Open Settings',
onPress: () => {
// You can use react-native-permissions to open settings
// Linking.openSettings();
},
},
]
);
}
/**
* Debug function to test permission flow
* Call this from your app to see detailed permission information
*/
export async function debugPermissionFlow(): Promise<void> {
const permission = getNotificationPermission();
const currentStatus = await checkNotificationPermission();
if (!currentStatus.granted && currentStatus.needsRequest) {
const requestResult = await requestNotificationPermission();
;
}
console.log('🔧 === END DEBUG ===');
}

233
utilities/webViewUtils.ts Normal file
View File

@ -0,0 +1,233 @@
/**
* WebView Utilities
* Handles WebView management, navigation, and message handling
*/
import { WebViewNavigation } from 'react-native-webview';
import { Linking, Platform } from 'react-native';
import { ALLOWED_DOMAIN } from './constants';
/**
* Interface for WebView message data
*/
export interface WebViewMessageData {
type?: string;
cookies?: string;
[key: string]: any;
}
/**
* Interface for WebView navigation state
*/
export interface WebViewNavigationState {
currentChannel: string | null;
isNavigating: boolean;
}
/**
* WebView Handler Class
*/
export class WebViewHandler {
private webViewRef: React.RefObject<any>;
private updateWebViewUrl: (url: string) => void;
private navigationState: WebViewNavigationState;
constructor(
webViewRef: React.RefObject<any>,
updateWebViewUrl: (url: string) => void,
navigationState: WebViewNavigationState
) {
this.webViewRef = webViewRef;
this.updateWebViewUrl = updateWebViewUrl;
this.navigationState = navigationState;
}
/**
* Handle WebView navigation requests
* @param request - Navigation request from WebView
* @returns boolean indicating whether to allow navigation
*/
handleNavigation(request: WebViewNavigation): boolean {
const { url } = request;
console.log('🧭 Navigation to:', url);
// Validate URL before processing
try {
new URL(url);
} catch (error) {
console.error("❌ Invalid URL detected:", url, error);
return false; // Block invalid URLs
}
// 🧪 Test notification when navigating to specific URL (temporary for testing)
if (url.includes('test-notification')) {
console.log('🧪 Test notification triggered via URL');
// You can trigger test notification here if needed
return false;
}
// ✅ Stay inside Odoo instance
if (
url.startsWith(`${ALLOWED_DOMAIN}`) ||
url.startsWith(`${ALLOWED_DOMAIN}`)
) {
return true;
}
// 🌍 Open external links in default browser
Linking.openURL(url).catch((err) =>
console.error("Couldn't open external link", err)
);
return false; // block WebView from handling it
}
/**
* Handle messages from WebView
* @param event - Message event from WebView
* @param setWebViewCookies - Function to set cookies
*/
handleWebViewMessage(
event: any,
setWebViewCookies: (cookies: string | null) => void
): void {
try {
const messageData = event.nativeEvent.data;
// Try to parse as JSON first
try {
const data: WebViewMessageData = JSON.parse(messageData);
console.log('📨 WebView JSON message received:', data);
// Handle cookie extraction (if needed)
if (data.type === 'cookies') {
console.log('🍪 Cookies from WebView:', data.cookies);
setWebViewCookies(data.cookies || null);
}
} catch (jsonError) {
// If JSON parsing fails, treat as plain text message
console.log('📨 WebView text message received:', messageData);
// Handle navigation feedback messages
if (messageData.startsWith('NAVIGATION_RESULT:')) {
const result = messageData.replace('NAVIGATION_RESULT:', '');
console.log('🔗 Navigation Result:', result);
}
// Handle other plain text messages here if needed
}
} catch (error) {
console.error('❌ Error handling WebView message:', error);
}
}
/**
* Handle WebView load completion
* @param currentChannel - Current channel being navigated to
* @param isNavigating - Whether navigation is in progress
*/
handleWebViewLoadEnd(currentChannel: string | null, isNavigating: boolean): void {
console.log('🔗 WebView loaded, current channel:', currentChannel, 'isNavigating:', isNavigating);
// Check if we successfully navigated to the target channel
if (currentChannel && this.webViewRef.current) {
console.log('🔗 WebView loaded - checking if navigation was successful');
// Add a simple debug script to check current URL
const checkScript = `
(function() {
try {
console.log('🔗 WebView loaded - Current URL:', window.location.href);
console.log('🔗 WebView loaded - Current hash:', window.location.hash);
// Check if we're on the target channel page
if (window.location.hash.includes('mail.channel_${currentChannel}')) {
console.log('✅ Successfully navigated to channel ${currentChannel}');
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage('NAVIGATION_RESULT:Successfully navigated to channel ${currentChannel}');
}
} else {
console.log('⚠️ Not on target channel page yet');
console.log('🔗 Expected: mail.channel_${currentChannel}');
console.log('🔗 Actual hash:', window.location.hash);
}
} catch (error) {
console.error('❌ Error in check script:', error);
}
})();
true;
`;
this.webViewRef.current.injectJavaScript(checkScript);
}
// If we're navigating and have a current channel, we might need to retry
if (currentChannel && isNavigating) {
console.log('🔗 Navigation in progress - checking if retry needed');
// Don't retry immediately, let the two-step navigation complete
} else if (currentChannel && !isNavigating) {
console.log('🔗 Navigation complete - no retry needed');
} else {
console.log('🔗 No active navigation');
}
}
/**
* Inject debug script into WebView
*/
injectDebugScript(): void {
if (this.webViewRef.current) {
const debugScript = `
(function() {
console.log('🔍 WebView Debug Info:');
console.log(' - Current URL:', window.location.href);
console.log(' - Current hash:', window.location.hash);
console.log(' - Document ready state:', document.readyState);
console.log(' - Available chat elements:', document.querySelectorAll('[class*="mail"], [class*="chat"], [data-model*="mail"]').length);
console.log(' - Available channel elements:', document.querySelectorAll('[data-channel-id], [data-id*="mail.channel"], [data-oe-id*="mail.channel"]').length);
// List all elements with channel-related attributes
const channelElements = document.querySelectorAll('[data-channel-id], [data-id*="mail.channel"], [data-oe-id*="mail.channel"], a[href*="mail.channel"]');
console.log('🔍 Channel elements found:', channelElements.length);
channelElements.forEach((el, index) => {
console.log(\` \${index + 1}. \${el.tagName} - \${el.className} - \${el.getAttribute('data-channel-id') || el.getAttribute('data-id') || el.getAttribute('href')}\`);
});
// Check Odoo availability
console.log('🔍 Odoo Debug:');
console.log(' - window.odoo exists:', !!window.odoo);
console.log(' - odoo.loader exists:', !!(window.odoo && window.odoo.loader));
console.log(' - odoo.services exists:', !!(window.odoo && window.odoo.services));
console.log(' - odoo.__DEBUG__ exists:', !!(window.odoo && window.odoo.__DEBUG__));
if (window.odoo && window.odoo.__DEBUG__) {
const services = Object.keys(window.odoo.__DEBUG__.services || {});
console.log(' - Available services:', services);
}
})();
true;
`;
this.webViewRef.current.injectJavaScript(debugScript);
}
}
/**
* Get WebView console messages
* @param event - Console message event
*/
handleConsoleMessage(event: any): void {
console.log('🌐 WebView Console:', event.nativeEvent.message);
}
}
/**
* Create WebView handler instance
*/
export function createWebViewHandler(
webViewRef: React.RefObject<any>,
updateWebViewUrl: (url: string) => void,
navigationState: WebViewNavigationState
): WebViewHandler {
return new WebViewHandler(webViewRef, updateWebViewUrl, navigationState);
}