6. Firebase
6. Firebase
In deze les integreren we Firebase in een React Native project.
Firebase is een backend-as-a-service (BaaS) van Google die alles voorziet die je nodig hebt om een volwaardige applicatie te bouwen. Dit betekent dat we Firebase kunnen gebruiken om authenticatie, database, file storage, hosting, functions, ... toe te voegen aan een applicatie. In deze les voegen we authenticatie en een (realtime) database toe aan onze app, daarnaast wordt in de appendix uitgelegd hoe je afbeeldingen kan uploaden naar Firebase.
Info
Er zijn verschillende alternatieven voor Firebase, zoals Supabase en Appwrite. Deze alternatieven zijn open-source en kunnen zelf gehost worden, daarnaast hebben ze elk hun voor en nadelen.
Je bent niet verplicht om Firebase te gebruiken in je project, we gebruiken deze BaaS hier omdat we zo, op eenvoudige wijze, Google authentication kunnen toevoegen en het gebruik van configuratiebestanden voor Google Services kunnen bespreken.
Startbestanden
Waarschuwing
Voor deze les is het cruciaal dat je een unieke package name/bundle identifier gebruikt. Firebase en Google hebben een unieke package name/bundle identifier nodig, anders krijg je de foutmelding 'DEVELOPER_ERROR' en werkt het uitloggen niet.
Voordat je aan deze les begint, is het dus nodig dat je de package name/bundle identifier aanpast in app.json, je kan bijvoorbeeld een suffix toevoegen met je r-nummer, naam, ...
Development build hashes
Elke Android applicatie moet ondertekend worden voordat deze geïnstalleerd kan worden op een toestel of geüpload kan worden op de Play Store. Tijdens development gebruikt Expo een standaard key, je kan deze natuurlijk overschrijven, maar dit doen we niet in deze les. In een productieomgeving moet je natuurlijk wel een eigen, unieke, en veilige key gebruiken. In het laatste hoofdstuk van deze cursus wordt beschreven hoe je dit kan doen.
Om gebruik te maken van Firebase (en de andere Google services) moet de fingerprint van de signing key toegevoegd worden aan het Firebase of Google Cloud project. Onderstaande commando's tonen hoe je de SHA1 & SHA 256 fingerprint kan opvragen voor een Android project (je moet natuurlijk wel eerst een prebuild uitvoeren). Aangezien Expo voor elke development build dezelfde key gebruikt, kan je deze commando's ook overslaan en de SHA hash rechtstreeks kopiëren naar je eigen project.
cd android; ./gradlew signingReport | Select-String -Pattern "SHA1" | Get-Unique; cd ..cd android && ./gradlew signingReport | grep 'SHA1' | sort | uniq && cd ..Deze commando's produceren minstens onderstaande uitvoer. Deze hash wordt gebruikt voor alle development builds. In les 7 bespreken we hoe we een productiebuild kunnen ondertekenen met een unieke hash.
SHA1: 5E:8F:16:06:2E:A3:CD:2C:4A:0D:54:78:76:BA:A6:F3:8C:AB:F6:25Firebase configureren
We beginnen met een nieuw Firebase project aan te maken, de stappen wijzen zichzelf uit en worden gedemonstreerd in onderstaande video.
Authentication activeren
De verschillende onderdelen van Firebase moeten individueel geactiveerd worden. We beginnen met authentication te activeren, in onderstaande video activeren we enkel Google authentication, de andere providers hebben meer configuratie nodig en worden als oefening gelaten voor de geïnteresseerde student.
Firestore activeren
Naast authentication activeren we ook Firestore, dit is een document database die eenvoudig gebruikt kan worden om realtime data te versturen.[1]
In bovenstaande video worden de security regels aangepast zodat enkel ingelogde gebruikers toegang hebben tot de database. Als je Firestore wilt gebruiken zonder authenticatie, kan je de security regels aanpassen zodat iedereen toegang heeft. Hieronder vind je de regels voor beide situaties.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if true;
}
}
}Android project toevoegen
Om Firebase te gebruiken in een Android applicatie moeten we het Android project registreren in Firebase, vervolgens moeten we de google-services.json downloaden en toevoegen aan het Expo project.
Onderstaande video demonstreert het configuratieproces, het is cruciaal dat de package name en SHA-1 fingerprint correct zijn en overeenkomen met de credentials die hierboven gegenereerd/opgevraagd zijn.
Als je Firebase, of Google Auth wilt gebruiken op Android, moet je SHA-1 hash van je production key ook toevoegen en google-services.json opnieuw downloaden.
iOS project toevoegen
Als je op een Mac werkt, kan je ook een iOS project toevoegen aan Firebase. Hiervoor moet je geen SHA fingerprint gebruiken maar enkel de bundle identifier (het package id). De App Store ID en Team ID velden mag je leeg laten aangezien deze enkel nodig zijn als je de applicatie ook echt wilt publiceren. Aangezien hiervoor een betalende Apple Developer account nodig is, bespreken we dit niet in deze cursus.
Google Services toevoegen
Zoals is bovenstaande video's gedemonstreerd, moeten we google-services.json (en voor iOS GoogleService-Info.plist) downloaden en deze bestanden toevoegen aan het native project.
De instructies op de Firebase website zijn bedoeld voor een Android of iOS project, maar we gebruiken Expo en moeten de bestanden dus op een andere locatie plaatsen. We plaatsen beide bestanden in de root van het project. Deze files zijn niet geheim en mogen dus gewoon toegevoegd worden aan git.
De locatie van beide bestanden moet doorgegeven worden aan Expo via app.json.
{
"expo": {
"name": "Chat",
"slug": "Chat",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "mobiledevlecture6example",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"splash": {
"image": "./assets/images/splash-icon.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true,
"bundleIdentifier": "be.pitgraduaten.chat",
"googleServicesFile": "./GoogleService-Info.plist"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "be.pitgraduaten.chat",
"googleServicesFile": "./google-services.json",
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": ["expo-router"],
"experiments": {
"typedRoutes": true
}
}
}Libraries installeren
Om authenticatie en database toe te voegen hebben we vier native modules nodig, @react-native-firebase/app, @react-native-firebase/auth, @react-native-google-signin/google-signin en @react-native-firebase/firestore. De eerste library gebruiken we om verbinding te maken met Firebase, de tweede library wordt gebruikt om authenticatie toe te voegen, de derde om in te loggen met Google via de account die op het Android toestel ingelogd is en de laatste om te communiceren met Firestore (database).
Belangrijk
De verschillende onderdelen van React Native Firebase tellen voor het project als één native library, ook al zijn er verschillende pnpm installs nodig, werken al deze onderdelen samen als één geheel.
De authenticatie libraries (@react-native-firebase/auth, @react-native-firebase/app en @react-native-google-signin/google-signin) moeten geconfigureerd via een config plugin.
{
...,
"plugins": [
"expo-router",
"@react-native-firebase/app",
"@react-native-firebase/auth",
"@react-native-google-signin/google-signin"
],
...
}
}iOS configuratie
Om de Firebase iOS SDK te gebruiken moeten we een extra aanpassing doen aan app.json. De useFramework optie moet op static gezet worden, zo wordt de Firebase SDK ingeladen als de app gecompileerd wordt. Voordat we dit kunnen doen, moeten we de expo-build-properties library installeren. Deze library maakt het mogelijk om de instellingen voor een native build aan te passen tijdens de prebuild-fase, het is, met andere woorden, mogelijk om de instellingen voor iOS en Android aan te passen via app.json.
Vervolgens kunnen we de useFramework optie toevoegen aan app.json.
{
...,
"plugins": [
"expo-router",
"@react-native-firebase/app",
"@react-native-firebase/auth",
"@react-native-google-signin/google-signin",
[
"expo-build-properties",
{
"ios": {
"useFrameworks": "static"
}
}
],
],
...
}
}:::
Deprecation warnings
React native firebase is momenteel bezig met een migratie van de oude namespaced API naar de nieuwere modular API. Alhoewel de volledige Firebase API beschikbaar is via de nieuwe modular API, zijn er niet voor elke methode TypeScript definitions beschikbaar. We moeten voorlopig dus nog gebruik maken van de oudere namespaced API. Elk van de functies in deze API produceren echter een deprecation warning.
Om deze foutmeldingen te onderdrukken tot de nieuwe API volledige TypeScript ondersteuning heeft, zetten we de globale RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS op true. De globalThis variabele is, een variable die gegarandeerd uniek is beschikbaar is in alle files zonder import.
Aangezien deze variabele ingesteld moet worden voordat react-native-firebase geladen wordt, stellen we deze in bovenaan in de root layout.
const globalThisForRNFB = globalThis as unknown as {RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS: boolean}
globalThisForRNFB.RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS = trueType hulpbestanden
We beginnen met een hulpbestand aan te maken dat het importeren van types eenvoudiger maakt. De @react-native-firebase library en alle sub-libraries exporteren namespaces die verschillende interfaces bevatten. Omdat het niet echt aangenaam is om telkens FirebaseAuthTypes.User te schrijven in plaats van User, bevatten de startbestanden een hulpbestand dat de namespaces importeert en alle properties daaruit exporteert.
import {FirebaseAuthTypes} from '@react-native-firebase/auth'
import {FirebaseFirestoreTypes} from '@react-native-firebase/firestore'
export type User = FirebaseAuthTypes.User
export type AuthCredential = FirebaseAuthTypes.AuthCredential
export type CollectionReference<T extends DocumentData> = FirebaseFirestoreTypes.CollectionReference<T>
export type DocumentData = FirebaseFirestoreTypes.DocumentData
export type QuerySnapshot<T extends DocumentData> = FirebaseFirestoreTypes.QuerySnapshot<T>
export type DocumentReference<T extends DocumentData> = FirebaseFirestoreTypes.DocumentReference<T>
export type DocumentSnapshot<T extends DocumentData> = FirebaseFirestoreTypes.DocumentSnapshot<T>Google auth implementeren
De startbestanden voorzien reeds enkele TanStack Query hooks die opgeroepen worden in de login pagina. De onderliggende functies van de hooks zijn echter nog niet geïmplementeerd, dit doen we hieronder.
Inloggen
De @react-native-firebase/auth library kan gebruikt worden om in te loggen met e-mail en GSM-nummer, voor elke andere provider moet een aparte library gebruikt worden. In dit geval gebruiken we @react-native-google-signin/google-signin om in te loggen via Google.
Info
Afhankelijk van de provider die je wilt gebruiker, kan je ofwel de documentatie van React Firebase Auth raadplegen of de documentatie van react-native-app-auth.
Als je tijdens je project gebruikt wilt maken van een andere provider dan email, Google of GSM-nummer, dan kan je jouw docent hierover contacteren, op voorwaarde dat je toont dat je moeite gedaan hebt om de documentatie te lezen en te implementeren.
Het inlogproces bestaat uit twee stappen, eerst moet een credential aangemaakt worden door in te loggen met de gekozen identity provider (in dit geval Google). Vervolgens kan de credential gebruikt worden om in te loggen bij Firebase. Dit proces kan eveneens gebruikt worden om in te loggen met Facebook, Apple, X en elke Open ID Connect provider. De manier waarop de credentials gecreëerd wordt is natuurlijk afhankelijk van de provider.
We schrijven hieronder alvast een algemene functie die uitgebreid kan worden met andere providers. De createGoogleCredential functie implementeren we later.
import auth from '@react-native-firebase/auth'
async function signIn({provider}: SignInParams): Promise<User | null> {
let credential: AuthCredential | null = null
switch (provider) {
case AuthProvider.GOOGLE:
credential = await createGoogleCredential()
break
default:
throw new Error('Invalid provider')
}
if (!credential) return null
const userCredential = await auth().signInWithCredential(credential)
return userCredential.user
}Credentials aanmaken
De @react-native-google-signin/google-signin module bestaat in twee versies, een betalende versie en een gratis versie. Wij gebruiken natuurlijk de gratis versie en dit brengt enkele beperkingen met zich mee, het is bijvoorbeeld niet mogelijk om in te loggen op de web-versie van de applicatie of om gebruik te maken van passkeys.
Deze documentatie beschrijft de twee methodes die we moeten oproepen om de credentials te verkrijgen.
configure
Via configure kunnen de API keys en id's ingesteld worden. Aangezien we gebruik maken van Firebase, moeten we via deze methode enkel webClientId toevoegen. Dit id is te vinden in google-services.json, wat we daarjuist gedownload hebben uit de Firebase console. In google-services.json kan je iets gelijkaardig aan onderstaande code vinden. Je hebt het client_id nodig met client_type 3.
"oauth_client": [
...
{
"client_id": "937664519323-26apm5drk8fvv7vg68dt018iar0g81lj.apps.googleusercontent.com",
"client_type": 3
}
],Dit id voegen we vervolgens toe aan de omgevingsvariabelen in .env. Merk op dat we de EXPO_PUBLIC_ prefix moeten gebruiken, hiermee geven we aan dat deze variabelen ook beschikbaar moeten zijn in de uiteindelijke production build.
EXPO_PUBLIC_WEB_CLIENT_ID=JOUW ID HIERNotitie
Je moet de .expo en android mappen verwijderen voordat de nieuwe omgevingsvariabelen beschikbaar zijn in de app.
signIn
De tweede methode, signIn, genereert de credentials die we nodig hebben om in te loggen bij Firebase.
Tenslotte zien we in de documentatie van React Firebase Auth dat we de credentials kunnen genereren via de GoogleAuthProvider klasse.
De drie methodes leiden uiteindelijk tot onderstaande code.
GoogleSignin.configure({webClientId: process.env.EXPO_PUBLIC_WEB_CLIENT_ID})
async function createGoogleCredential(): Promise<AuthCredential | null> {
// Check if your device supports Google Play
await GoogleSignin.hasPlayServices({showPlayServicesUpdateDialog: true})
// Get the users ID token
const signInResult = await GoogleSignin.signIn()
// Retrieve the ID Token
const idToken = signInResult.data?.idToken
if (!idToken) {
throw new Error('No ID token found')
}
// Create a Google credential with the token
return auth.GoogleAuthProvider.credential(idToken)
}Gebruiker ophalen
Om de gebruiker om te halen kunnen we de currentUser property aanspreken.
export function getCurrentUser(): User | null {
return auth().currentUser
}Uitloggen
Om uit te loggen moeten we zowel bij Firebase als bij Google uitloggen, beide sessies zijn actief en het is onmogelijk om in te loggen met een andere account tenzij we op beide niveaus uitloggen. Ook deze procedure herhaalt zich voor de andere providers.
async function signOut(): Promise<void> {
const user = getCurrentUser()
if (user === null) {
return
}
// Log uit bij Firebase.
await auth().signOut()
// Log uit bij de Identity Provider.
switch (user.providerData[0].providerId) {
case AuthProvider.GOOGLE.toString():
await GoogleSignin.signOut()
break
default:
throw new Error('Invalid provider')
}
}Beschermde routes
De startbestanden bevatten al code in index.tsx en login.tsx die de gebruiker redirect naar de login pagina of de chat pagina, afhankelijk van de status van de gebruiker.
Om problemen te vermijden is het best om alle routes, behalve de login pagina, te beschermen en te garanderen dat de gebruiker deze onmogelijk kan bezoeken als hij/zij niet ingelogd is.
Hiervoor kunnen we de layout-route van de chat pagina aanpassen en een extra redirect toevoegen die ervoor zorgt dat we geredirect worden naar de login pagina als de gebruiker uitlogt.
const TabsLayout: FunctionComponent = () => {
const user = useUser()
if (!user) {
return <Redirect href="/login/login" />
}
return (
<Tabs>
{/* ... */}
</Tabs>
)
}Firestore
De UI en alle hooks voor het verzenden en ophalen van boodschappen zijn al geïmplementeerd in de startbestanden, we moeten enkel de API-code nog implementeren. De CRUD-operaties worden geïmplementeerd via TanStack Query, voor meer informatie verwijzen we door naar les 4 en les 5 van Frontend Frameworks.
We beginnen met klassieke CRUD-operaties te implementeren, aan het einde van de les voegen we real-time data toe.
Collection & document references
Om informatie op te halen uit Firestore moeten we eerst een referentie maken naar de collectie of het document dat we willen raadplegen ophalen. Hiervoor schrijven we twee nieuwe hulpmethodes die opnieuw gebruik maken van de types die beschikbaar zijn in firebaseTypes.ts.
De DocumentData klasse die hieronder gebruikt wordt is een interface die alle objecten accepteert die uitsluitend string keys bevatten. Het type van de values die gelinkt zijn aan deze properties is niet belangrijk.
Om een referentie naar een collectie op te vragen hebben we enkel de naam van de collectie nodig, om een referentie naar een document op te vragen hebben we ook het id van dit document nodig.
import type {CollectionReference, DocumentData, DocumentReference} from '@/models/firebaseTypes'
import {collection, getFirestore} from '@react-native-firebase/firestore'
export function getCollectionRef<T extends DocumentData>(collectionName: string): CollectionReference<T> {
return collection(getFirestore(), collectionName) as CollectionReference<T>
}
export function getDocumentRef<T extends DocumentData>(collection: string, documentId: string): DocumentReference<T> {
return getCollectionRef<T>(collection).doc(documentId)
}documentData
Via de get methode van een DocumentSnapshot kunnen we de data van een document ophalen, deze methode geeft een object van het type DocumentReference terug. Deze laatste interface bevat naast de informatie die we willen ophalen ook heel wat metadate. De meerderheid van deze data hebben we niet nodig, via de data methode kan de eigenlijke inhoud van het document opgehaald worden.
Een document bevat het bijhorende id standaard niet, dit id kan opgevraagd worden via de DocumentReference.id property. Via de idField parameter kunnen we aangeven in welke property van het generische object T het id bewaard moet worden.
Als type van deze parameter gebruiken we Extract<keyof T, string>. Dit type bestaat uit twee delen. Via keyof T definiëren we een type dat alle keys (properties) van het type T bevat. Via Extract<keyof T, string> geven we aan dat we alle properties van het object T accepteren die het type string hebben.
Tenslotte gebruiken we vierkante haken in de definitie van het return-object om de parameter uit te lezen.
export async function documentData<T extends DocumentData>(
collection: string,
documentId: string,
idField: Extract<keyof T, string>,
): Promise<T | undefined> {
const documentSnapshot = await getDocumentRef<T>(collection, documentId).get()
return getDataFromDocumentSnapshot<T>(documentSnapshot, idField)
}
export function getDataFromDocumentSnapshot<T extends DocumentData>(
snapshot: DocumentSnapshot<T>,
idField: Extract<keyof T, string>,
): T | undefined {
const data: T | undefined = snapshot.data()
if (data) {
return {
...data,
[idField]: snapshot.id,
}
}
return undefined
}collectionData
Via de get methode van een CollectionReference kunnen we alle documenten in een collectie ophalen. Omdat we alle documenten in een collectie ophalen, krijgen we een QuerySnapshot terug. Dit is een array van DocumentSnapshot objecten.
export async function collectionData<T extends DocumentData>(collection: string, idField: Extract<keyof T, string>) {
const collectionSnapshot = await getCollectionRef<T>(collection).get()
return getDataFromQuerySnapshot<T>(collectionSnapshot, idField)
}
export function getDataFromQuerySnapshot<T extends DocumentData>(
snapshot: QuerySnapshot<T>,
idField: Extract<keyof T, string>,
): T[] {
return snapshot.docs.map(doc => {
return {
...doc.data(),
[idField]: doc.id,
}
})
}Berichten ophalen
Om een bericht op te halen kunnen we natuurlijk de collectionData methode gebruiken, maar deze methode sorteert de berichten niet op datum en wel op basis van het id van de documenten. Omdat het id willekeurig gegenereerd wordt, kunnen we de collectionData methode niet gebruiken, maar moeten we de getCollectionRef functie gebruiken en vervolgens de getDataFromQuerySnapshot functie.
const getMessages = async (): Promise<IMessage[]> => {
const querySnapshot = await getCollectionRef<IMessage>('messages').orderBy('date', 'asc').get()
return getDataFromQuerySnapshot(querySnapshot, 'id')
}Bericht versturen
Om een bericht te versturen kunnen we de add methode van een CollectionReference gebruiken. Deze methode geeft een referentie naar het nieuwe document terug. We kunnen de referentie vervolgens gebruiken om de data van het nieuwe document op te halen.
const sendMessage = async ({content}: SendMessageParams): Promise<IMessage> => {
const user = getCurrentUser()
if (!user) {
throw new Error('User not logged in')
}
const docRef = await getCollectionRef<IMessage>('messages').add({
content,
date: Date.now(),
userId: user.uid,
profilePicture: user.photoURL ?? `https://ui-avatars.com/api/?name=${user.displayName}`,
userName: user.displayName ?? 'Anonymous',
})
const message = await documentData<IMessage>('messages', docRef.id, 'id')
return message as IMessage
}Bericht verwijderen
Om een bericht te verwijderen kunnen we de delete methode van de DocumentReference interface gebruiken.
const deleteMessage = async ({id}: DeleteMessageParams): Promise<void> => {
await getDocumentRef<IMessage>('messages', id).delete()
}Bericht updaten
Er zijn twee manieren om een bericht te updaten, de eerste optie is de update methode van de DocumentReference, deze methode stelt ons in staat om één specifieke property aan te passen. De tweede optie is de set methode, hiermee kunnen we de volledige inhoud van het document overschrijven.
const updateMessageContent = async ({id, content}: UpdateMessageParams): Promise<IMessage> => {
await getDocumentRef<IMessage>('messages', id).update('content', content)
const updatedMessage = await documentData<IMessage>('messages', id, 'id')
return updatedMessage as IMessage
}
const updateMessage = async (message: IMessage & {id: string}): Promise<IMessage> => {
await getDocumentRef<IMessage>('messages', message.id).update(message)
const updatedMessage = await documentData<IMessage>('messages', message.id, 'id')
return updatedMessage as IMessage
}Real-time data
Bovenstaande functies werken perfect als data beschikbaar is voor één gebruiker. Als data gesynchroniseerd moet worden tussen verschillende gebruikers hebben we real-time data nodig.
Via de onSnapshot methode van de CollectionData interface kunnen we abonneren op wijzigingen in de data. Hiervoor hebben we een useEffect hook nodig omdat we moeten abonneren op wijzigingen in data en dit abonnement moet natuurlijk opgezegd worden als de component unmount wordt, als we dit niet doen, riskeren we memory leaks. Voor meer informatie verwijzen we naar les 5 van Frontend Frameworks.
export const useRealtimeMessages = (): IMessage[] => {
const [messages, setMessages] = useState<IMessage[]>([])
useEffect(() => {
const unsubscribe = getCollectionRef<IMessage>('messages')
.orderBy('date', 'asc')
.onSnapshot(snapshot => setMessages(getDataFromQuerySnapshot(snapshot, 'id')))
return () => {
unsubscribe()
}
}, [])
return messages
}const Chat: FunctionComponent = () => {
const messages = useRealtimeMessages()
const {mutate: sendMessage} = useSendMessage()
const [message, setMessage] = useState<string>('')
const [inputHeight, setInputHeight] = useState(56)
return (
<VStack className="flex h-full">
...
</VStack>
)
}Voorbeeldcode
Volledig uitgewerkt lesvoorbeeld met commentaar
Firebase bevat naast Firestore ook de Realtime Database, deze is veel minder krachtig dan Firestore en wordt dus niet gebruikt in deze cursus. ↩︎