React-navigation: Deep linking with authentication - react-native

I am building a mobile app with react-native and the react-navigation library for managing the navigation in my app. Right now, my app looks something like that:
App [SwitchNavigator]
Splash [Screen]
Auth [Screen]
MainApp [StackNavigator]
Home [Screen] (/home)
Profile [Screen] (/profile)
Notifications [Screen] (/notifications)
I have integrated Deep Linking with the patterns above for the screens Home, Profile and Notifications, and it works as expected. The issue I am facing is how to manage my user's authentication when using a deep link. Right now whenever I open a deep link (myapp://profile for instance) the app takes me on the screen whether or not I am authenticated. What I would want it to do is to check before in AsyncStorage if there is a userToken and if there isn't or it is not valid anymore then just redirect on the Auth screen.
I set up the authentication flow in almost exactly the same way as described here. So when my application starts the Splash screen checks in the user's phone if there is a valid token and sends him either on the Auth screen or Home screen.
The only solution I have come up with for now is to direct every deep link to Splash, authentify my user, and then parse the link to navigate to the good screen.
So for example when a user opens myapp://profile, I open the app on Splash, validate the token, then parse the url (/profile), and finally redirect either to Auth or Profile.
Is that the good way to do so, or does react-navigation provide a better way to do this ? The Deep linking page on their website is a little light.
Thanks for the help !

My setup is similar to yours. I followed Authentication flows · React Navigation and SplashScreen - Expo Documentation to set up my Auth flow, so I was a little disappointed that it was a challenge to get deep links to flow through it as well. I was able to get this working by customizing my main switch navigator, the approach is similar to what you stated was the solution you have for now. I just wanted to share my solution for this so there’s a concrete example of how it’s possible to get working. I have my main switch navigator set up like this (also I’m using TypeScript so ignore the type definitions if they are unfamiliar):
const MainNavigation = createSwitchNavigator(
{
SplashLoading,
Onboarding: OnboardingStackNavigator,
App: AppNavigator,
},
{
initialRouteName: 'SplashLoading',
}
);
const previousGetActionForPathAndParams =
MainNavigation.router.getActionForPathAndParams;
Object.assign(MainNavigation.router, {
getActionForPathAndParams(path: string, params: any) {
const isAuthLink = path.startsWith('auth-link');
if (isAuthLink) {
return NavigationActions.navigate({
routeName: 'SplashLoading',
params: { ...params, path },
});
}
return previousGetActionForPathAndParams(path, params);
},
});
export const AppNavigation = createAppContainer(MainNavigation);
Any deep link you want to route through your auth flow will need to start with auth-link, or whatever you choose to prepend it with. Here is what SplashLoading looks like:
export const SplashLoading = (props: NavigationScreenProps) => {
const [isSplashReady, setIsSplashReady] = useState(false);
const _cacheFonts: CacheFontsFn = fonts =>
fonts.map(font => Font.loadAsync(font as any));
const _cacheSplashAssets = () => {
const splashIcon = require(splashIconPath);
return Asset.fromModule(splashIcon).downloadAsync();
};
const _cacheAppAssets = async () => {
SplashScreen.hide();
const fontAssetPromises = _cacheFonts(fontMap);
return Promise.all([...fontAssetPromises]);
};
const _initializeApp = async () => {
// Cache assets
await _cacheAppAssets();
// Check if user is logged in
const sessionId = await SecureStore.getItemAsync(CCSID_KEY);
// Get deep linking params
const params = props.navigation.state.params;
let action: any;
if (params && params.routeName) {
const { routeName, ...routeParams } = params;
action = NavigationActions.navigate({ routeName, params: routeParams });
}
// If not logged in, navigate to Auth flow
if (!sessionId) {
return props.navigation.dispatch(
NavigationActions.navigate({
routeName: 'Onboarding',
action,
})
);
}
// Otherwise, navigate to App flow
return props.navigation.navigate(
NavigationActions.navigate({
routeName: 'App',
action,
})
);
};
if (!isSplashReady) {
return (
<AppLoading
startAsync={_cacheSplashAssets}
onFinish={() => setIsSplashReady(true)}
onError={console.warn}
autoHideSplash={false}
/>
);
}
return (
<View style={{ flex: 1 }}>
<Image source={require(splashIconPath)} onLoad={_initializeApp} />
</View>
);
};
I create the deep link with a routeName query param, which is the name of the screen to navigate to after the auth check has been performed (you can obviously add whatever other query params you need). Since my SplashLoading screen handles loading all fonts/assets as well as auth checking, I need every deep link to route through it. I was facing the issue where I would manually quit the app from multitasking, tap a deep link url, and have the app crash because the deep link bypassed SplashLoading so fonts weren’t loaded.
The approach above declares an action variable, which if not set will do nothing. If the routeName query param is not undefined, I set the action variable. This makes it so once the Switch router decides which path to take based on auth (Onboarding or App), that route gets the child action and navigates to the routeName after exiting the auth/splash loading flow.
Here’s an example link I created that is working fine with this system:
exp://192.168.1.7:19000/--/auth-link?routeName=ForgotPasswordChange&cacheKey=a9b3ra50-5fc2-4er7-b4e7-0d6c0925c536
Hopefully the library authors will make this a natively supported feature in the future so the hacks aren’t necessary. I'd love to see what you came up with as well!

On my side I achieved this without having to manually parse the route to extract path & params.
Here are the steps:
getting the navigation action returned by: getActionForPathAndParams
passing the navigation action to the Authentication view as param
then when the authentication succeed or if the authentication is already ok I dispatch the navigation action to go on the intended route
const previousGetActionForPathAndParams = AppContainer.router.getActionForPathAndParams
Object.assign(AppContainer.router, {
getActionForPathAndParams(path: string, params: NavigationParams) {
const navigationAction = previousGetActionForPathAndParams(path, params)
return NavigationActions.navigate({
routeName: 'Authentication',
params: { navigationAction }
})
}
})
Then In the Authentication view:
const navigationAction = this.navigation.getParam('navigationAction')
if (navigationAction)
this.props.navigation.dispatch(navigationAction)

I ended up using a custom URI to intercept the deeplink launch, and then passing those params to the intended route. My loading screen handles the auth check.
const previousGetActionForPathAndParams = AppContainer.router.getActionForPathAndParams
Object.assign(AppContainer.router, {
getActionForPathAndParams (path, params) {
if (path === 'auth' && params.routeName && params.userId ) {
// returns a profile navigate action for myApp://auth?routeName=chat&userId=1234
return NavigationActions.navigate({
routeName: 'Loading',
params: { ...params, path },
})
}
return previousGetActionForPathAndParams(path, params)
},
})
https://reactnavigation.org/docs/en/routers.html#handling-custom-uris
Then, in my Loading Route, I parse the params like normal but then route to the intended location, passing them once again.
const userId = this.props.navigation.getParam('userId')
https://reactnavigation.org/docs/en/params.html

I found an easier way, i'm maintaining the linking in a separate file and importing it in the main App.js
linking.js
const config = {
screens: {
Home:'home',
Profile:'profile,
},
};
const linking = {
prefixes: ['demo://app'],
config,
};
export default linking;
App.js
& during login I keep the token inside async storage and when user logs out the token is deleted. Based on the availability of token i'm attaching the linking to navigation and detaching it using state & when its detached it falls-back to SplashScreen.
Make sure to set initialRouteName="SplashScreen"
import React, {useState, useEffect} from 'react';
import {Linking} from 'react-native';
import AsyncStorage from '#react-native-async-storage/async-storage';
import {createStackNavigator} from '#react-navigation/stack';
import {NavigationContainer} from '#react-navigation/native';
import linking from './utils/linking';
import {Home, Profile, SplashScreen} from './components';
const Stack = createStackNavigator();
// This will be used to retrieve the AsyncStorage String value
const getData = async (key) => {
try {
const value = await AsyncStorage.getItem(key);
return value != null ? value : '';
} catch (error) {
console.error(`Error Caught while getting async storage data: ${error}`);
}
};
function _handleOpenUrl(event) {
console.log('handleOpenUrl', event.url);
}
const App = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
// Checks if the user is logged in or not, if not logged in then
// the app prevents the access to deep link & falls back to splash screen.
getData('studentToken').then((token) => {
if (token === '' || token === undefined) setIsLoggedIn(false);
else setIsLoggedIn(true);
});
Linking.addEventListener('url', _handleOpenUrl);
return () => {
Linking.removeEventListener('url', _handleOpenUrl);
};
}, []);
return (
//linking is enabled only if the user is logged in
<NavigationContainer linking={isLoggedIn && linking}>
<Stack.Navigator
initialRouteName="SplashScreen"
screenOptions={{...TransitionPresets.SlideFromRightIOS}}>
<Stack.Screen
name="SplashScreen"
component={SplashScreen}
options={{headerShown: false}}
/>
<Stack.Screen
name="Home"
component={Home}
options={{headerShown: false, gestureEnabled: false}}
/>
<Stack.Screen
name="Profile"
component={Profile}
options={{headerShown: false, gestureEnabled: false}}
/>
</Stack.Navigator>
</NavigationContainer>
);
};
export default App;
When a logged in user opens the deep link from notification then it will take him to the respective deep linked screen, if he's not logged in then it will open from splash screen.

I created a package for Auto Deep linking with Authentication Flow.
You can try it. auth-linking https://github.com/SohelIslamImran/auth-linking
auth-linking
Auto Deep linking with Authentication Flow
Deep linking is very easy to use with authentication. But some people take it hard way. So this package will help you to achieve the easiest way to handle Deep linking with Authentication Flow.
Installation
npm install auth-linking
yarn add auth-linking
Usage
AuthLinkingProvider
You need to wrap your app with AuthLinkingProvider.
import AuthLinkingProvider from "auth-linking";
...
const App = () => {
return (
<AuthLinkingProvider onAuthChange={onAuthChange}>
{/* Your app components */}
</AuthLinkingProvider>
);
};
export default App;
onAuthChange prop
You need to provide an onAuthChange prop to AuthLinkingProvider. This a function that should return a promise with the user or truthy value (if logged in) and null or falsy (if the user is not logged in).
const onAuthChange = () => {
return new Promise((resolve, reject) => {
onAuthStateChanged(auth, resolve, reject);
});
};
...
<AuthLinkingProvider onAuthChange={onAuthChange}>
useAutoRedirectToDeepLink
Call this hook inside a screen that will render after all auth flow is completed. So this hook will automatically redirect to the deep link through which the app is opened.
import { useAutoRedirectToDeepLink } from "auth-linking";
...
const Home = () => {
useAutoRedirectToDeepLink()
return (
<View>{...}</View>
);
};
All done.

Related

Synchronization Problem - Try to log navigation and store the log by AsyncStorage

I am tring to log the navigation between screens by useEffect with using the useIsFocusd. I have customized that the logs store locally by AsyncStorage, but I found some logs have disappeared. I think it should be a synchronization problem, but async/await is not available in useEffect.
Any suggestions to my case?
let log=await AsyncStorage.getItem('log')
log=JSON.parse(log)
/Such as, ABC Screen navigated
log.push(props.msg+'\n')
/*I think the problem happens when there are multiple logs write at the same time. For example, 1 read 2 read, and 2 write 1 write. In this case, log 2 cannot be saved, but I dont know how to tackle it*/
await AsyncStorage.setItem('log',JSON.stringify(log));
I don‘t think that is the correct way to log the screen, you would have to implement logging for ich screen you wanna log.
In case you are using react navigation you can grab the screen in the NavigationContainer.
import {
NavigationContainer,
useNavigationContainerRef,
} from '#react-navigation/native';
export default () => {
const navigationRef = useNavigationContainerRef();
const routeNameRef = useRef();
return (
<NavigationContainer
ref={navigationRef}
onReady={() => {
routeNameRef.current = navigationRef.getCurrentRoute().name;
}}
onStateChange={async () => {
const previousRouteName = routeNameRef.current;
const currentRouteName = navigationRef.getCurrentRoute().name;
const trackScreenView = () => {
// Your implementation of analytics goes here!
};
if (previousRouteName !== currentRouteName) {
// Save the current route name for later comparison
routeNameRef.current = currentRouteName;
// Replace the line below to add the tracker from a mobile analytics SDK
await trackScreenView(currentRouteName);
}
}}
>
{/* ... */}
</NavigationContainer>
);
};
Explanation

Ionic React - Navigate to a page when an FCM notification is tapped

I am implementing FCM notifications in an Ionic React application. I am having trouble navigating to another page to display the notification details.
I have created a FCMService class in my react App, and initialising this in the index.ts file.
// FCMService.ts
export default class FCMService {
public static Instance: FCMService;
private _store: Store<IAppState>;
constructor(store: Store<IAppState>) {
this._store = store;
}
public static Initalise(store: Store<IAppState>) {
if (!FCMService.Instance) {
FCMService.Instance = new FCMService(store);
FCMService.Instance.InitaliseFCM();
FCMService.Instance._store.subscribe(() => { console.log(store.getState()) });
} else {
console.debug("FCM service already intialised. Please use FCMService.Instance");
}
}
private InitaliseFCM() {
// Request permission to use push notifications
// iOS will prompt user and return if they granted permission or not
// Android will just grant without prompting
PushNotifications.requestPermission().then(result => {
console.log(result);
if (result.granted) {
// Register with Apple / Google to receive push via APNS/FCM
PushNotifications.register();
} else {
// Show some error
}
});
// On success, we should be able to receive notifications
PushNotifications.addListener('registration', (token: PushNotificationToken) => {
console.log(token);
localStorage.setItem("FCM_TOKEN", token.value);
}
);
// Some issue with our setup and push will not work
PushNotifications.addListener('registrationError',
(error: any) => {
console.log(error);
}
);
// Show us the notification payload if the app is open on our device
PushNotifications.addListener('pushNotificationReceived',
(notification: PushNotification) => {
console.log(notification);
let data = notification.notification.data as INotificationData;
}
);
// Method called when tapping on a notification
PushNotifications.addListener('pushNotificationActionPerformed',
(notification: PushNotificationActionPerformed) => {
console.log(notification);
let data = notification.notification.data as INotificationData;
this._store.dispatch(setNotificationActionCreator(data));
}
);
}
}
and then the index.ts
const store = configureStore();
interface MainProps {
store: Store<IAppState>;
}
FCMService.Initalise(store);
ReactDOM.render(<Provider store={store}><App /> </Provider>, document.getElementById('root'));
serviceWorker.unregister();
I even tried using the Redux store to save the notification on Tap - and then that would publish the notification change event (which might of worked - if I could access the useHistory() hook in the App.tsx file)
This was my attempt at navigating via Redux store in App.tsx
const App: React.FC<IProps> = ({ getCompanies, getUser, notification }) => {
console.log('app');
console.log(process.env);
const history = useHistory();
if(notification){
history.push(`/page/plot-position/{notification.id}`);
}
return (
<IonApp>
<IonReactRouter>
<IonSplitPane contentId="main" when="false">
<Menu />
<IonRouterOutlet id="main">
<Route path="/login" component={LoginPage} exact />
<PrivateRoute path="/page/plot-position/:notificationId/" component={PlotPositionPage} exact />
<Redirect from="/" to="/login" exact />
</IonRouterOutlet>
</IonSplitPane>
</IonReactRouter>
</IonApp>
);
};
const mapStateToProps = (store: IAppState) => {
return {
user: store.user.user as UserDTO,
notification: store.notificationState.notification
};
};
const mapDispatchToProps = (dispatch: any) => {
return {
getCompanies: () => dispatch(getCompaniesStartActionCreator()),
getUser: () => dispatch(getUserStartActionCreator())
}
};
export default connect(mapStateToProps, mapDispatchToProps)(App);
It looks like your navigation works, but you're having trouble passing the notification object through to the page? You can pass the object through history state.
To access the useHistory hook you would need to make your FCMService a custom hook.
const useFCMService = (): void => {
const history = useHistory();
React.useEffect(() => {
// Method called when tapping on a notification
PushNotifications.addListener('pushNotificationActionPerformed',
(action: PushNotificationActionPerformed) => {
const notification = action.notification.data as INotificationData;
history.push({ pathname: '/page/plot-position/', state: { notification } });
}
);
}, []);
}
And then include your useFCMService custom hook in your App component.
const App: React.FC<IProps> = ({ getCompanies, getUser }) => {
useFCMService();
...
};
Deep linking provides us a way to do this: Using both an action to open the application and an action at opening the application we can enroute the user to the correct destination.
Opening the application
Here we will create an action to open the url when the user taps on the push notification; to do this less use a listener:
const {PushNotifications, App} = Plugins
***
PushNotifications.addListener(
"pushNotificationActionPerformed",
(notification: PushNotificationActionPerformed) =>{
const data = notification.notification.data;
if (data.packageNumber) App.openUrl({url: `com.company.appname://tabs/package-details/${data.packageNumber}`})
else App.openUrl({url:'/tabs'})
}
)
com.company.app:// is of capital importance since the app must reach the application must reach an existing given url, otherwise the following action(catching the url) won't be triggers since it waits a complete true from the App.openUrl function; as we are opening an internal url, this must begin with the apps given name in the capacitor config page(see the following example where we can realize how use the local url).
In this way we are adding a function to open the application in an specific route.
Redirecting the user
Here, we will complete the application's part from the deep linking tutorial: we create a new listener component who handles the appOpenUrl events and redirects to the user and we will put it on the main App file inside of its respective IonRouter:
const AppUrlListener: React.FC<any> = () => {
let history = useHistory();
useEffect(() => {
App.addListener('appUrlOpen', (data: any) => {
const slug = data.url.split(':/').pop();
if (slug) {
history.push(slug);
}
});
}, []);
return null;
};
Don't forget the route in router must begin with /, and since the application url contains :/, we split the url here and we get the second part, the slug; we push it on the history, triggering the router and getting the normal behaviour when you entering in a new route.
We will add this component inside of the router:
<IonReactRouter>
<IonSplitPane contentId="main">
<Menu />
<AppUrlListener />
<IonRouterOutlet id="main">
Now, the application will be listening the appOpenUrl event, and when it gets a new of this events, it will push the gotten url to the history, redirecting the user to that route.

Using React-Navigation v5 with Redux & Firebase Authentication

I am trying to use React-Navigation Version 5 (not Ver 4) to switch from the authentication screens/stack to the non-authentication screens/stack when the user logs in. A simple example is well demonstrated in the documentation.
However, the documentation does not provide details for using Redux and Google Firebase authentication. After a successful login, I am getting the following warning message.
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
I think that the cause of this warning message is that the call to Redux action (action creator) causes re-rendering of the authentication screen, while the navigation code has already unmounted this screen! How can I update the Redux's store after the authentication screen has been unmounted?
Navigation Code:
...
// If user is logged in -> display the app/main stack
if (props.loggedIn)
{
const MyTabs = createBottomTabNavigator();
return (
<NavigationContainer>
<MyTabs.Navigator initialRouteName="UserTypeScreen">
<MyTabs.Screen name="UserTypeScreen" component={UserTypeScreen} />
<MyTabs.Screen name="PermissionsScreen" component={PermissionsScreen} />
</MyTabs.Navigator>
</NavigationContainer>
);
}
// If user is NOT logged in -> display the auth stack
else {
const AuthStack = createStackNavigator();
return (
<NavigationContainer>
<AuthStack.Navigator>
<AuthStack.Screen name="AuthScreen" component={AuthScreen} />
<AuthStack.Screen name="AuthLinkScreen" component={AuthLinkScreen} />
</AuthStack.Navigator>
</NavigationContainer>
);
}
...
Authentication screen:
...
useEffect( () => {
const unSubscribe = firebase.auth().onAuthStateChanged( (user) => {
if (user) {
const idToken = firebase.auth().currentUser.getIdToken();
const credential = firebase.auth.PhoneAuthProvider.credential(idToken);
// The following statement causes a warning message, because it causes re-rendering of an unmounted screen
props.acLoginUserSuccess(user, credential);
}
});
return () => {
unSubscribe();
};
}, []);
...
Redux Action Creator:
...
export const acLoginUserSuccess = (user, credential) => {
return {
type: LOGIN_USER_SUCCESS,
payload: { user: user, credential: credential }
};
};
...
I have quickly gone through you code, but here's what I think (could be wrong).
The docs recommend fetching the token on a screen which is mounted (App.js in the example).
This will not be an issue if your firebase.auth() listener in useEffect hook is in App.js .
I think the warning message is shown because when you are in MyTabs route, the AuthScreen is not mounted.
Moving the listener to App.js or on a screen which is in the same stack as BOTH the auth and MyTabs screen will solve the problem I think.
I believe that below code is written with idea to get token and store in Redux store.
Is it possible to move this logic after validation of credentials is successful instead of
unmount?
please provide valid reason of having this logic in unmount to assess the problem better
firebase.auth().onAuthStateChanged( (user) => {
if (user) {
const idToken = firebase.auth().currentUser.getIdToken();
const credential = firebase.auth.PhoneAuthProvider.credential(idToken);
// The following statement causes a warning message, because it causes re-rendering of an unmounted screen
props.acLoginUserSuccess(user, credential);
}
});

Deep linking into an already open screen with different params. Is it possible?

I'm working on an app which has a category screen where the relevant posts of the category are displayed. I'm using react-navigation to navigate between the screens and handle the deep linking. The category screen can be accessed in-app or via deep link. To access the category screen via deep link I'm using something like myapp://category/:id.
If the app is already opened and is focused on the category screen the deep link does nothing.
I've currently "fixed" it using the componentDidUpdate life cycle method to compare the ID stored in the state and the ID in navigation.getParam.
componentDidUpdate = () => {
const { navigation } = this.props;
const { categoryID } = this.state;
const paramID = navigation.getParam('id', null);
if (paramID !== categoryID) {
this.setState({ categoryID: paramID }, () => {
// fetch data
});
}
}
However, this seems like a dirty fix to me. Is there a better way of doing this? Maybe opening all deep links using push instead of navigate via react-navigation?
For anyone who got here wondering how this can be done, like myself, here's the solution:
You can use the getId Stack.Screen property. You just need to return the unique ID from the getId property. Here's an example:
Let's say we have a UserScreen component, which is part of our Stack navigator. The UserScreen has one route param: userId. We can use the getId property like this:
import { createStackNavigator } from '#react-navigation/stack';
const Stack = createStackNavigator();
const AppStack = () => {
const getUserRouteId = ({ params }) => {
return params && params.userId;
};
return (
<Stack.Navigator>
{/* Other routes */}
<Stack.Screen name="User" component={UserScreen} getId={getUserRouteId} />
</Stack.Navigator>;
);
};
Now when you try to normally navigate to this route, using the navigate function, if the param userId in the navigation action is different than on the UserScreen which you're currently on, it will navigate you to a new user screen. React Navigation deep linking uses the navigate function internally.

How to show login page instead of home page

I'm using the default bottom tab navigation app from the expo example
My appnavigator.js looks like this
export default createAppContainer(createSwitchNavigator({
// You could add another route here for authentication.
// Read more at https://reactnavigation.org/docs/en/auth-flow.html
Main: MainTabNavigator,
}));
I want to check if the user is logged in, by checking the asyncstorage loginname value. In the home.js, if there is no loginname, then I want to redirect to the sigin.js page.
I suggest creating a file called initializing.js as a screen, which will be the first entry point in the app and put the logic there.
export default class Initializing extends Component {
async componentDidMount() {
try {
const token = await AsyncStorage.getItem('user_token');
const skipOnBoarding = true;
const authenticated = true;
if (token) await goToHome(skipOnBoarding, authenticated);
else await goToAuth();
SplashScreen.hide();
} catch (err) {
await goToAuth();
SplashScreen.hide();
}
}
render() {
return <View />;
}
}
I worked it out. was very easy.
First do this in the appNavigator.js
import SignIn from '../screens/SignIn'
export default createAppContainer(createSwitchNavigator({
// You could add another route here for authentication.
// Read more at https://reactnavigation.org/docs/en/auth-flow.html
Main: MainTabNavigator,
SignIn: SignIn // signIn is the login page
}));
Next, at the logic where you check for user logined do something like this.
AsyncStorage.getItem('loginname').then((value) => {
console.log(value)
this.props.navigation.navigate('SignIn')
})
the prop this.props.navigation.navigate is automatically avaiable in every stack, you dont need to pass it around to use it