5. Firebase
5. Firebase
Tijdens deze les bouwen we een (realtime) chat applicatie met behulp van Firebase. We gebruiken dit platform om authenticatie (Google & phone) te voorzien en om onze database te hosten.
In de startbestanden is de UI is zo goed als volledig af, de code bevat geen nieuwigheden en dus heeft het weinig zin deze tijdens de les te bespreken. De interacties met de database en de authentication plug-in wordt doorheen de les toegevoegd. In de uitgewerkte versie is Firebase nog niet geconfigureerd, noch in het Angular project, noch in het Android project.
Startbestanden
Firebase
Firebase is een door Google ontwikkelde back-end as a service (BaaS), een BaaS is een platform dat databases, hosting, authentication, analytics en andere zaken aanbiedt via een webinterface. Als programmeur moet we ons dus niet bezig houden met het installeren, configureren, en beveiligen van servers.
Firebase kan geïntegreerd worden in Android, web en iOS projecten en biedt een heel uitgebreide free-tier waarmee dit getest kan worden, de specifieke limitaties zijn te raadplegen op https://firebase.google.com/pricing.
Voor deze les heb je een gratis Firebase (Google) account nodig, deze kan aangemaakt worden op https://console.firebase.google.com/. Het aanmaken van een project wijst zichzelf uit.
Firebase Project configureren
Een Firebase project moet apart geconfigureerd worden voor de Android, iOS en web platformen. Aangezien onze applicaties gebouwd worden met Angular en we via Capacitor native functionaliteiten aanspreken, zullen we zowel de Android als web configuraties moeten uitvoeren. Indien je de applicatie wilt aanbieden op iOS, moet er natuurlijk ook een iOS project aangemaakt worden.
Web configuratie
Voor het webplatform is weinig configuratie nodig, we moeten enkel API keys generen en deze toe te voegen aan het Angular project. Onderstaande video toont hoe je dit doet in Firebase.
De Firebase configuratie die je gekopieerd hebt, moet vervolgens in het Angular project geplaatst worden. Maak hiervoor gebruik van de environment variables die we in de vorige les besproken hebben.
Notitie
Kopieer onderstaande API keys niet in jouw project, je moet zelf een Firebase project aanmaken. Onderstaande keys zijn gekoppeld aan een echt Firebase project, gebruik je eigen keys.
export const environment = {
production: false,
firebaseConfig: {
apiKey: 'AIzaSyBBizDHZg9ayeRcLyCaFG2w3PotpO8Dt-Ac',
authDomain: 'les6-9b015.firebaseapp.com',
projectId: 'les6-9b015',
storageBucket: 'les6-9b015.appspot.com',
messagingSenderId: '333692764893',
appId: '1:333692464893:web:da7d8a2398def3918e9c94'
}
};export const environment = {
production: true,
firebaseConfig: {
apiKey: 'AIzaSyBBizDHZg9ayeRcLyCaFG2w3PotpO8Dt-Ac',
authDomain: 'les6-9b015.firebaseapp.com',
projectId: 'les6-9b015',
storageBucket: 'les6-9b015.appspot.com',
messagingSenderId: '333692764893',
appId: '1:333692464893:web:da7d8a2398def3918e9c94'
}
};Android applicatie ondertekenen
Elke Android applicatie moet ondertekend zijn, als dit niet het geval is wordt de applicatie afgewezen door de Google Play store of door de installer op het Android toestel als de applicatie via sideloading geïnstalleerd wordt.
Van bovenstaande problemen merk je pas iets als je de applicatie wilt verdelen naar gebruikers, maar ook voor deze les is het belangrijk dat je applicatie ondertekend is. Firebase controleert op het moment dat de SDK geïnitialiseerd wordt in een Android applicatie of deze ondertekend is en of de handtekening overeenkomt met diegene die op Firebase gekoppeld is aan het project. Als deze controles falen, crasht de applicatie.
Om deze handtekening te genereren hebben we Android Studio en de Java Runtime Environment nodig, hoe je die laatste configureert staat beschreven in het hoofdstuk over de development environment.
Via Android Studio genereren we een keystore, dit is een bestand waarin de sleutels die gebruikt worden om de app te ondertekenen bewaard worden. Vervolgens kunnen we via keytool.exe, een programma dat meegeleverd wordt met de Java RE (en dus ook met Android Studio), de hash van deze sleutels ophalen. Deze hashes moeten we dan toevoegen aan Firebase.
We genereren voor de startbestanden een Android project, zoals beschreven in les 3.
Genereren van een keystore
Onderstaande video toont hoe de sleutel gegenereerd kan worden met Android Studio.
Info
Er is niets dat je weerhoudt om dezelfde sleutel te gebruiken voor verschillende apps, voor dit vak zullen we echter een sleutel genereren per project en deze sleutel in de root van het Angular project bewaren. Zo kan de sleutel mee in een git repository gezet worden, dit stelt je docent in staat om de applicatie volledig te testen en te debuggen.
Onderstaande video toont dat je een wachtwoord moet ingeven. Gebruik hiervoor een wachtwoord dat je nergens anders gebruikt, dit wachtwoord zal namelijk in plain-tekst bewaard worden in je Android project (en op git voor je eindproject).
Waarschuwing
Voor een echte applicatie is het natuurlijk een heel slecht idee om deze wachtwoorden mee op git te zetten, hier zijn natuurlijk wel oplossingen voor. We gebruiken deze echter niet in de cursus omdat dit het heel moeilijk maakt voor de docenten om je Android project op hun computer te openen en te testen. Voor meer informatie over een veiligere oplossing verwijzen we door naar de Android developer docs.
Bovenstaande video toont ook hoe je een .apk kan genereren, dit is echter een optionele stap. Om zeker te zijn dat alles correct werkt kan je dit uitvoeren, maar normaal gezien zou het voldoende moeten zijn om na het aanmaken van de key te stoppen.
Voor je project moet je dit menu wel volledig afwerken en een .apk genereren.
Ophalen van de SHA-hashes
Om de sleutel toe te voegen aan Firebase hebben we de SHA1-hash en SHA256-hash van deze sleutel nodig, om de hashes op te halen gebruiken we keytool.exe.
Het ophalen van deze sleutels kan door middel van onderstaand terminal-commando. In het onderstaande commando, gaan we er van uit dat de keystore aangemaakt is in de root van je Angular project, zoals hierboven getoond en dat je het commando in deze map uitvoert. Verder gaan we er ook van uit dat de alias gelijk is aan diegene die gebruikt is in bovenstaande video.
keytool -list -v -alias "Les5" -keystore key.jksHet resultaat ziet er ongeveer als volgt uit, de belangrijke lijnen zijn gemarkeerd.
Notitie
Kopieer onderstaande hashes niet in jouw project, je moet zelf een keystore aanmaken en de hashes opvragen.
Enter keystore password:
Alias name: Les5
Creation date: 8 nov. 2021
Entry type: PrivateKeyEntry
Certificate chain length: 1
Certificate[1]:
Owner: CN=Sebastiaan Henau, L=Kasterlee, ST=Antwerpen, C=BE
Issuer: CN=Sebastiaan Henau, L=Kasterlee, ST=Antwerpen, C=BE
Serial number: 3e6ae7ab
Valid from: Mon Nov 08 13:20:31 CET 2021 until: Fri Nov 02 13:20:31 CET 2046
Certificate fingerprints:
SHA1: 95:02:DC:9D:6D:A3:5B:F3:19:71:92:6B:B1:DA:34:13:98:A7:B9:01
SHA256: 36:68:0C:4D:EA:F3:36:5B:6F:DD:FA:1B:39:BB:7C:2C:11:CB:64:59:5A:75:F1:CB:B9:27:DF:68:57:88:FC:4A
Signature algorithm name: SHA256withRSA
Subject Public Key Algorithm: 2048-bit RSA key
Version: 3Hashes toevoegen aan Firebase
Om de SHA-hashes toe te voegen aan Firebase creëren we een nieuwe Android app binnen Firebase. Vervolgens voegen we de SHA-hashes toe en tenslotte downloaden we het bestand google-services.json, dit bestand moet toegevoegd worden aan het Android project in Android Studio en bevat de configuratie die de Firebase SDK gebruikt om zich te initialiseren.
Notitie
In onderstaande video, wordt een package name opgegeven. Dit moet dezelfde naam zijn als diegene die gebruikt is in jouw Android project, kopieer de ingevulde waarden in het voorbeeld dus niet zonder nadenken. Ook de nickname moet volledig overeen komen met de appName in jouw project.
google-services.json
Het bestand google-services.json, dat hierboven gedownload is, moet toegevoegd worden aan het Android project. Hiervoor open je het project in Android Studio en selecteer je de Project-view zit. Vervolgens open je de android map (waar een pad achterstaat, er zijn er meerdere) en plak je het gedownloade bestand in de map /android/app.

Signing configs gebruiken
Om de keystore te gebruiken als je op run drukt, moeten onderstaande stappen nog doorlopen worden. Deze configuratie zorgt er voor dat de test versies van de app die je via Android studio op een emulator of Android toestel installeert, correct ondertekent zijn. Zonder deze configuratie, is het niet mogelijk om via Google of GSM in te loggen.
Firebase configureren voor authenticatie
Firebase biedt ondersteuning voor een hele reeks providers waarmee gebruikers kunnen inloggen. Tijdens deze les zullen we enkel gebruik maken van Google en phone login.
De andere providers zijn ook eenvoudig te implementeren, maar aangezien de configuratie vereist dat je een developer account aanmaakt bij deze providers en dit heel veel klik werkt vereist, doen we dit niet in deze les. Je kan eventueel extra providers toevoegen in je project, op de GitHub pagina van de authentication plug-in zijn links te vinden naar guides voor elke provider.
Firebase & Angular
Om Firebase te gebruiken in een Angular applicatie moeten we een aantal libraries installeren. De officiële Firebase SDK heeft TypeScript bindings, maar heeft geen goede Angular integratie. Aangezien zowel Angular als Firebase door Google ontwikkeld worden, is er een oplossing. Angular Fire biedt volledige Firebase ondersteuning in een Angular project.
Waarschuwing
Er zijn regelmatig problemen omwille van incompatibele dependencies tussen Angular, Firebase, Angular Fire, RxJS, TypeScript, tslib en RxFire. Door version mismatches kan het zijn dat de applicatie niet compileert, omdat we in deze cursus willen focussen op het schrijven van Angular code, onderdrukken we deze errors door tsconfig.json aan te passen. Natuurlijk doen we dit enkel dit soort fouten voorkomen.
{
"compileOnSave": false,
"compilerOptions": {
...
"skipLibCheck": true
},
"angularCompilerOptions": {
...
}
}:::
Om Firebase in te laden in een project, moeten we Firebase importeren in de app.module. Merk op dat we hier de informatie uit de environments inladen.
import {provideFirebaseApp, initializeApp} from '@angular/fire/app';
import {environment} from '../environments/environment';
import {getAuth, provideAuth} from '@angular/fire/auth';
@NgModule({
declarations: [AppComponent],
entryComponents: [],
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
// Firebase main import.
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
// Firebase authentication import.
provideAuth(() => getAuth())
],
providers: [{provide: RouteReuseStrategy, useClass: IonicRouteStrategy}],
bootstrap: [AppComponent],
})
export class AppModule {}Notitie
Let op tijdens het automatisch genereren van de imports. De Firebase SDK en Angular Fire bieden nog ondersteuning voor de oude manier van werken die gebruikt werd in Firebase 8. Aangezien dit minder performant is en op termijn zal verdwijnen, gebruiken we deze oude import statements niet. De nieuwe versies bieden betere support voor Tree Shaking waardoor de production-build kleiner wordt. Gebruik dus enkel import statements waarin het woord compat niet voorkomt.
Responsive apps
Tot nu toe hebben we nog niet echt naar responsiviteit gekeken, onze apps waren gefocust op mobile devices. Als we de app ook als PWA willen aanbieden op desktop systemen, is dit niet ideaal. Via de <ion-split-pane> component, kunnen we een side menu verplaatsen naar de zijkant zodra de viewport groot genoeg is. Standaard zal dit gebeuren als de viewport meer dan 992px breed is, dit komt overeen met het lg breakpoint dat ook in frameworks zoals Bootstrap gebruikt wordt.
Let op, de ion-split-pane component kan slechts één keer gebruikt worden in eenzelfde project.
<ion-app>
<ion-split-pane contentId="main">
<!-- the side menu -->
<ion-menu contentId="main">
<ion-content *ngIf="authService.isLoggedIn()">
<ion-item>
<ion-avatar slot="start">
<img [src]="authService.getProfilePic()" #avatar (error)="avatar.src = placeholder" alt="avatar">
</ion-avatar>
<ion-label>
<ion-list-header>
{{authService.getDisplayName()}}
</ion-list-header>
<ion-note class="ion-margin-start">
{{authService.getEmail()}}
</ion-note>
</ion-label>
</ion-item>
<ion-list lines="none">
<ion-item (click)="authService.signOut()">
Logout
</ion-item>
</ion-list>
</ion-content>
</ion-menu>
<!-- the main content -->
<ion-router-outlet id="main"></ion-router-outlet>
</ion-split-pane>
</ion-app>Social log-in
We bouwen een chat applicatie waarbij gebruikers kunnen inloggen via Google en hun GSM-nummer. Om dit te implementeren is Angular Fire niet voldoende en moeten we een extra plug-in gebruiken.
Angular Fire is bedoeld voor webapplicaties en opent tijdens het inloggen een nieuw venster. Na het inloggen wordt er een callback URL gebruikt die de gebruiker redirect naar de webapplicatie en de nodige data bevat om de gebruiker in te loggen. De callback vormt het probleem, het openen van het nieuwe venster lukt zonder problemen, maar de callback faalt omdat de app in een webview draait en niet op een webserver. De callback wordt via de browser op het mobiele toestel afgehandeld en omdat de webview op localhost draait, zal de callback verwijzen naar http://localhost. Er draait natuurlijk geen webserver op het Android toestel, de webserver die onze app gebruikt is enkel beschikbaar in de app en niet op het volledige toestel. Daarbovenop maken de web-methodes geen gebruik van gebruikersaccounts die al gedefinieerd zijn in het Android systeem. Dit betekent dat de gebruiker, zelfs als het account al geregistreerd is op het toestel, het e-mailadres en wachtwoord van het Googleaccount moet intypen, helemaal niet gebruiksvriendelijk dus.
Capacitor, heeft zelf geen (gratis) plug-in die gebruikt kan worden om in te loggen, gelukkig is er een community alternatief. Capacitor Firebase Authentication is een gratis plug-in die het inloggen afhandelt door middel van Java, Swift of JavaScript code, afhankelijk van het platform. Op het moment van schrijven ondersteunt deze plug-in volgende providers:
- Apple
- Game Center
- GitHub
- Microsoft (personal & Azure AD)
- Play Games
- X (Twitter)
- Yahoo
- Anonymous
- Phone (enkel op iOS & Android)
- Custom Token
We installeren deze plug-in met onderstaand commando.
Capacitor configureren
Om de plug-in werkende te krijgen is een beetje configuratie nodig, we moeten de Capacitor configuratie uitbreiden zodat de juiste providers geladen worden.
De optie skipNativeAuth in onderstaande configuratie, stelt ons in staat om in te loggen in de native layer, i.e. het Android/iOS systeem. Omdat de plug-in app op de native layer werkt kunnen we gebruik maken van accounts die al aanwezig zijn op het systeem. Zo moet de gebruiker niet opnieuw inloggen met een Googleaccount (op Android), maar wordt de bestaande systeem account gedetecteerd, de gebruiker moet deze enkel nog selecteren.
import { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'be.thomasmore.graduaten.chat',
appName: 'Les 5 Chat',
webDir: 'www',
server: {
androidScheme: 'https',
},
plugins: {
FirebaseAuthentication: {
skipNativeAuth: false,
providers: ['phone', 'google.com'],
},
},
}Om deze wijzigingen over te zetten naar het Android project, voeren we onderstaand commando uit.
Android project aanpassen
De authentication plug-in laad standaard zo weinig mogelijk externe SDK's in. In de documentatie is voor elke provider te vinden of er een SDK toegevoegd moet worden en hoe je dit dan doet. Om in te loggen via Google moet het bestand variables.gradle aangepast worden. Voor phone authentication is er geen extra SDK nodig.
Onderstaande wijzigingen doe je best in Android Studio.
ext {
minSdkVersion = 22
compileSdkVersion = 33
targetSdkVersion = 33
androidxActivityVersion = '1.7.0'
androidxAppCompatVersion = '1.6.1'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.10.0'
androidxFragmentVersion = '1.5.6'
coreSplashScreenVersion = '1.0.0'
androidxWebkitVersion = '1.6.1'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
cordovaAndroidVersion = '10.1.1'
rgcfaIncludeGoogle = true
}Inloggen
Voor een PWA is het volledige inlogproces en alle bijhorende code al beschikbaar in de startbestanden. Hier is bijzonder weinig over te zeggen, de plug-in die we gebruiken bevat methodes zoals signInWithGoogle en signInWithPhoneNumber. Alles wat wij moeten doen, is deze methodes oproepen. Vervolgens handdelen Firebase en de plug-in alles af. Inclusief het persistent maken van de userstate, als de app opnieuw opstart, zal de gebruiker dus nog steeds ingelogd zijn.
Voor een native platform is het proces iets ingewikkelder. Op Android of iOS wordt de gebruiker ingelogd op de native layer via Java of Swift code. Dit is echter alles wat de plug-in doet, de gebruiker wordt standaard dus niet ingelogd in de web-layer, i.e. de effectieve app. Inloggen moet via de native layer gaan, omwille van de hierboven besproken problemen met Angular Fire. Het resultaat van de signInWithGoogle methode kan daarna gebruikt worden om in te loggen in de web-layer.
De singInWithGoogle methode geeft onderstaande data terug (natuurlijk zijn de accessToken en idToken anders voor elke login-poging).
{
"providerId": "google.com",
"accessToken": "ya29.a0ARrdaM_Mr57xGsJg...",
"idToken": "eyJhbGciOiJS..."
}We zien hier enkele interessante eigenschappen. Als we deze vergelijken met de documentatie voor de GoogleAuthProvider klasse zien we dat we de credential methode kunnen gebruiken om een OAuthCredential object te bouwen, dat object kunnen we vervolgens doorgeven aan de signInWithCredential methode uit Angular Fire.
import {Auth, signInWithCredential, GoogleAuthProvider} from '@angular/fire/auth'
import {FirebaseAuthentication} from '@capacitor-firebase/authentication'
export class AuthService {
#auth = inject(Auth)
// Niet relevante code weggelaten.
async signInWithGoogle(): Promise<void> {
// Sign in on the native layer.
const authResult = await FirebaseAuthentication.signInWithGoogle()
const idToken = authResult?.credential?.idToken
if (!idToken) {
throw new Error('Authentication did not succeed, please try again.')
}
// Sign in on the web layer.
if (Capacitor.isNativePlatform()) {
const credential = GoogleAuthProvider.credential(idToken)
await signInWithCredential(this.#auth, credential)
}
}
}Notitie
Alhoewel bovenstaande denkwijze geldig is voor alle providers (behalve de PhoneAuthProvider), is het niet mogelijk om de GoogleAuthProvider zomaar te vervangen met een andere AuthProvider. Elk van de providers heeft andere parameters nodig, zoals gedemonstreert in onderstaande screenshots.


Natuurlijk moeten we ook bij het uitloggen rekening houden met de verschillende lagen, doen we dit niet, dan zal de gebruiker enkel uitgelogd worden op de native laag in een Android of iOS applicaties.
import {signOut} from '@angular/fire/auth'
import {FirebaseAuthentication} from '@capacitor-firebase/authentication'
export class AuthService {
// Niet relevante code weggelaten.
async signOut(): Promise<void> {
await FirebaseAuthentication.signOut()
if (Capacitor.isNativePlatform()) {
await signOut(this.#auth)
}
}
}Notitie
De Android versie van Firefox heeft standaard problemen met authenticatie methodes waarvoor een webbrowser gebruikt wordt. In alle andere geteste browsers werkt het wel. Onderstaande video toont welke instelling je aan moet zetten om toch via de Android versie van Firefox te kunnen authenticeren.
Guards
Angular guards kunnen gebruikt worden om te controleren of een bepaalde route geladen mag worden. Er bestaan verschillende soorten guards, zo is er bijvoorbeeld de canActivate guard die bepaalt of een gebruiker geautoriseerd is om een bepaalde route te bezoeken. Via de canLoad guard kan je het lazy-loading van een route stoppen als de gebruiker niet geautoriseerd is om deze te openen. Dit laatste is bijvoorbeeld nuttig om geen onnodig werk te doen als de gebruiker geen administrator is (in de applicatie), in dat geval heeft het geen zin om de admin pagina's in te laden. Het is binnen Angular
Angular Fire bevat een guard die gebruikt kan worden om een route enkel beschikbaar te maken als de gebruiker ingelogd is. We kunnen deze eenvoudig toevoegen aan de routing modules.
import {AuthGuard} from '@angular/fire/auth-guard'
const routes: Routes = [
{
path: 'login',
loadChildren: () => import('./login/login.module').then(m => m.LoginPageModule),
},
{
path: 'channel/:channelName',
loadChildren: () => import('./channel/channel.module').then(m => m.ChannelPageModule),
canActivate: [AuthGuard],
},
{
path: '',
pathMatch: 'full',
redirectTo: 'channel/General',
},
]Zoals in onderstaande video te zien is, is enkel een guard toevoegen niet voldoende. De guard verhinderd de gebruiker enkel om een bepaalde route te bezoeken, maar een niet geauthenticeerde gebruiker zou doorgestuurd moeten worden naar de login.
De AuthGuard module van Angular Fire biedt hier ook een oplossing voor, de guard heeft een optionele parameter redirectUnauthorizedTo waaraan een functie meegegeven wordt die opgeroepen wordt als de gebruiker niet geauthenticeerd is.
import {AuthGuard, redirectUnauthorizedTo} from '@angular/fire/auth-guard'
const routes: Routes = [
{
path: 'login',
loadChildren: () => import('./login/login.module').then(m => m.LoginPageModule),
},
{
path: 'channel/:channelName',
loadChildren: () => import('./channel/channel.module').then(m => m.ChannelPageModule),
canActivate: [AuthGuard],
data: {authGuardPipe: () => redirectUnauthorizedTo(['login'])},
},
{
path: '',
pathMatch: 'full',
redirectTo: 'channel/General',
},
]We kunnen de AuthService vervolgens uitbreiden zodat niet-ingelogde gebruikers naar de '/login' pagina gestuurd worden en ingelogde gebruikers naar het pad '/'.
export class AuthService {
#currentUser: BehaviorSubject<null | User> = new BehaviorSubject<null | User>(null)
currentUser = this.#currentUser.asObservable()
#auth = inject(Auth)
#router = inject(Router)
constructor() {
this.#auth.onAuthStateChanged(user => this.#setCurrentUser(user))
}
// Niet relevante code weggelaten.
async #setCurrentUser(user: User | null): Promise<void> {
this.#currentUser.next(user)
if (this.#currentUser.value) {
await this.#router.navigate(['/'])
} else {
await this.#router.navigate(['/login'])
}
}
}De authStateChanged callback kan gebruikt worden om de actieve pagina te wijzigen naar '/' zodra de gebruiker gedetecteerd is, maar om te garanderen dat deze code uitgevoerd wordt aan de start van de applicatie, moeten we de AuthService injecteren in de AppComponent.
export class AppComponent {
authService = inject(AuthService)
}Bovenstaande code leidt tot onderstaande log-in flow.
Firestore
Firebase biedt twee soorten database aan, de real-time database en Firestore. De real-time database kan gebruikt worden om één JSON-object te bewaren, all wijzigingen worden automatisch gesynchroniseerd naar alle verbonden clients. Aangezien hier slechts één object gebruikt wordt, is dit niet toepasbaar voor alle doeleinden. Dit soort database mag enkel gebruikt worden als er heel veel kleine updates moeten gebeuren en als er geen complexe queries gesteld moeten worden.
De tweede database, Firestore, is een echte document database. Dit betekent dat we JSON-objecten bewaren als een document in een collection. Firestore kan, net zoals de real-time database, gebruikt worden om wijzigingen onmiddellijk te synchroniseren naar alle verbonden clients. In tegenstelling tot de real-time database, kan Firestore ook gebruikt worden om snapshots van de data op te halen.
Naast deze features, kan Firestore ook gebruikt worden om complexe queries te schrijven en kan de toegang tot de database afgeschermd worden zodat enkel geautoriseerde gebruikers toegang hebben. Deze beveiliging ligt echter buiten de scope van onze cursus, we zullen hier niet ver op ingaan en elke geauthenticeerde gebruiker de rechten geven om data te lezen, te bewerken en te verwijderen.
Tenslotte kan Firestore ook gebruikt worden om automatisch een lokale cache te bewaren van de database. Zo werkt de applicatie ook als deze offline is. Nieuwe of aangepaste documenten worden dan gesynchroniseerd zodra de gebruiker terug verbonden is met het internet. Ideaal dus voor mobiele applicaties die offline moeten werken.
Firestore configureren
Om Firestore te activeren is er relatief weinig werk nodig. We moeten opnieuw naar de Firebase webinterface gaan en daar een paar keer klikken. Firestore kan voor 30 dagen gebruikt worden in "Test Mode", wat inhoudt dat alle documenten en collecties aangemaakt, bewerkt en gelezen kunnen worden door iedereen die de API keys heeft.
Voor dit vak, is "Test Mode" niet echt een probleem, aangezien we onze applicaties niet willen publiceren op de Play of App Store en iedereen zijn eigen Firebase account aanmaakt, maar de 30 dagen zijn natuurlijk wel niet ideaal, zeker als je Firestore zou gebruiken in je project. Het is dus aan te raden om de regels voor de database aan te passen, hieronder vindt je twee mogelijke configuraties.
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;
}
}
}Firestore gebruiken
Zoals eerder gezegd, bouwen we een chat app. Deze app is opgedeeld in verschillende kanalen, waarin de gebruikers berichten kunnen posten. De UI voor deze kanalen is al beschikbaar in de startbestanden, enkel de database moet nog geprogrammeerd worden.
Eerder in deze les hebben we Firebase en de authentication module al ingeladen in het Angular project, nu voegen we Firestore hieraan toe. We moeten dit apart doen voor elk onderdeel van Firebase dat we gebruiken, dus ook voor Firestore. In onderstaande code, importeren we Firebase niet alleen, maar activeren we ook de offline persistentie.
import {
initializeFirestore,
persistentLocalCache,
persistentMultipleTabManager,
provideFirestore,
} from '@angular/fire/firestore'
import {getApp, initializeApp, provideFirebaseApp} from '@angular/fire/app'
@NgModule({
declarations: [AppComponent],
entryComponents: [],
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
// Firebase main import.
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
// Firebase authentication import.
provideAuth(() => getAuth()),
// Firestore database import
provideFirestore(() => initializeFirestore(getApp(), {
localCache: persistentLocalCache({tabManager: persistentMultipleTabManager()})
})),
],
providers: [{provide: RouteReuseStrategy, useClass: IonicRouteStrategy}],
bootstrap: [AppComponent],
})
export class AppModule {}Notitie
Let voor het vervolg van deze les zeer goed op de imports. Zoals eerder gezegd zijn er verschillende versies beschikbaar in Angular Fire. Een verkeerde import zorgt voor een crash en kan, ondanks eenzelfde naam, een andere signatuur hebben.
Alle imports voor Firestore moeten uit @angular/fire/firestore komen.
Collection & Document references
Om bewerkingen uit te kunnen voeren op een document of collectie moeten we een referentie bouwen naar de locatie in de database waar dit object zicht bevind.
De eerste stap is het injecteren van Firestore in de DatabaseService. We injecteren de AuthService ook, zo kunnen we het uuid van de ingelogde gebruiker opvragen.
import {Firestore} from '@angular/fire/firestore'
import {AuthService} from './auth.service'
@Injectable({
providedIn: 'root'
})
export class DatabaseService {
#authService = inject(AuthService)
#firestore = inject(Firestore)
}Om de referenties op te vragen, schrijven we twee hulpmethodes. De functies die in Firebase beschikbaar zijn om een document of collection referentie te genereren zijn niet generic, terwijl de functies waarmee een document opgehaald of verwijderd kan worden dit wel zijn. We maken de hulpmethodes generisch, zo kunnen we een referentie naar een collectie van Message of Channel objecten creëren. Deze twee interfaces zijn al aanwezig in de startbestanden en zien er als volgt uit:
export interface IMessage {
content: string
user: string
profile: string
displayName?: string
id?: string
date: number
}export interface IChannel {
name: string
isPublicChannel: boolean
users?: string[]
key?: string
owner?: string
}De eerste helper methode krijgt de naam van een collection als argument en geeft een referentie van het type CollectionReference<T, K> terug. De dubbele generische parameter is nodig omdat Firebase een onderscheid maakt tussen de data die in de database zit en de datastructuur die client-side gebruikt wordt. Client-side kan een class gebruikt worden in de plaats van een interface, dit is nuttig als er methodes nodig zijn om de data te manipuleren. In deze cursus gebruiken we echter heel eenvoudige data-types, daardoor is dit onderscheid niet nodig en zal het type van de twee generische parameters dus steeds gelijk zijn.
import {
collection,
CollectionReference,
Firestore,
DocumentData,
} from '@angular/fire/firestore'
export class DatabaseService {
// Niet relevante code weggelaten.
#getCollectionRef<T extends DocumentData>(collectionName: string): CollectionReference<T, T> {
return collection(this.#firestore, collectionName) as unknown as CollectionReference<T, T>
}
}Deze methode kan vervolgens gebruikt worden om een referentie te verkrijgen naar een collectie die elementen van een bepaald type bevat. In het voorbeeld hieronder verkrijgen we dus een referentie naar de collectie general waarin IMessage objecten zitten.
this.#getCollectionRef<IMessage>('general')Alhoewel we hier een root-level collectie gebruikt hebben, is het ook perfect mogelijk om geneste collecties te gebruiken.
this.#getCollectionRef<IMessage>('general/subchannel/subsubchannel')We kunnen iets gelijkaardigs doen voor de referentie naar een document. Dit keer hebben we niet enkel de naam van de collectie nodig, maar ook het id van het specifieke document waarnaar we willen verwijzen.
import {
collection,
CollectionReference,
Firestore,
doc,
DocumentReference
} from '@angular/fire/firestore'
export class DatabaseService {
// Niet relevante code weggelaten.
#getDocumentRef<T extends DocumentData>(collectionName: string, id: string): DocumentReference<T, T> {
return doc(this.#firestore, `${collectionName}/${id}`) as unknown as DocumentReference<T, T>
}
}Aanmaken van een bericht
Nu we de referenties kunnen ophalen, wordt het mogelijk om een boodschap toe te voegen aan een chat kanaal. Hiervoor schrijven we een nieuwe functie sendMessage die de tekst van de boodschap en het kanaal waarin de boodschap geplaatst moet worden als argument krijgt.
import {
addDoc,
collection,
doc,
Firestore,
CollectionReference,
DocumentReference,
} from '@angular/fire/firestore'
export class DatabaseService {
// Niet relevante code weggelaten.
async sendMessage(channel: string, message: string): Promise<void> {
const user = this.#authService.getUserUid()
if (!user) {
throw new Error('Authenticated')
}
const newMessage: IMessage = {
content: message,
user,
displayName: this.#authService.getDisplayName(),
profile: this.#authService.getProfilePic(),
date: Date.now(),
}
await addDoc<IMessage, IMessage>(
this.#getCollectionRef<IMessage>(channel),
newMessage,
)
}
}We gebruiken deze methode vervolgens in de ChannelPage component. Enkel de inhoud van de methode sendMessage moet nog ingevuld worden, de event-binding en de 2-way binding voor het formulier zijn al gebeurd in de startbestanden.
import {DatabaseService} from '../services/database.service';
export class ChannelPage implements OnInit {
// Niet relevante code weggelaten.
channelName = 'General'
newMessage: string
#dbService = inject(DatabaseService)
sendMessage(): void {
if (this.newMessage) {
this.#dbService.sendMessage(this.channelName, this.newMessage).then()
this.newMessage = undefined
}
}
}Firestore is zeer krachtig, we moeten geen schema aanmaken, geen collecties definiëren, ... Als we de addDoc functie gebruiken, worden de nodige collecties automatisch aangemaakt. Dit is gedemonstreerd in onderstaande video.
Uitlezen van berichten
Om een document op te halen kan de docData methode gebruikt worden, om een collectie van verschillende documenten op te halen de collectionData methode. De collectionData methode verwacht een query argument, dit argument bevat de referentie naar een collection. Zowel docData als collectionData geven een Observable<T> terug, in tegenstelling tot vorige les worden de observables niet automatisch afgesloten nadat de eerste data uitgestuurd is. Firestore werkt standaard met real-time data, telkens dat de data op Firestore wijzigt, zend de observable nieuwe data uit.
De idField waarde die op lijn 20 meegegeven wordt, zorgt ervoor dat de id property in de IMessage objecten correct opgevuld wordt. Als je dit stukje code niet meegeeft, heb je in de teruggegeven data geen toegang tot het id van een bericht. Als je het id in een andere property dan id wilt bewaren, dan kan je dit doen als {idField: 'someOtherProperty'}
import {
addDoc,
collection,
collectionData,
CollectionReference,
doc,
DocumentReference,
Firestore,
query,
} from '@angular/fire/firestore'
export class DatabaseService {
// Niet relevante code weggelaten.
retrieveMessages(channel: string): Observable<IMessage[]> {
return collectionData<IMessage>(
query<IMessage, IMessage>(
this.#getCollectionRef(channel),
),
{idField: 'id'},
)
}
}De methode om één document op te halen is te vinden in het uitgewerkte lesvoorbeeld, aangezien deze niet gebruikt wordt in deze applicatie en heel gelijkaardig is, wordt deze verder niet besproken.
retrieveMessages kan vervolgens gebruikt worden om een instantievariabele te initialiseren in de ChannelPage component.
export class ChannelPage implements OnInit {
// Niet relevante code weggelaten.
channelName = 'General'
#dbService = inject(DatabaseService)
messagesObservable: Observable<IMessage[]> = this.#dbService.retrieveMessages(this.channelName)
}Zoals in onderstaande video geïllustreerd wordt, zien we de boodschappen nu wel verschijnen, maar er doet zich nog een belangrijk probleem voor. De boodschappen staan niet in volgorde van publicatie. De volgorde wordt bepaald door ASCII waarden van karakters in de willekeurig gegenereerde uuids en niet door de publicatiedatum.
De query parameter in de retrieveMessages methode kan uitgebreid worden met QueryConstraints, dit zijn methodes zoals orderBy, where, ... De volledige lijst is te raadplegen in de firebase documentatie
import {
addDoc,
collection,
collectionData,
CollectionReference,
doc,
DocumentReference,
Firestore,
orderBy,
query,
} from '@angular/fire/firestore'
export class DatabaseService {
// Niet relevante code weggelaten.
retrieveMessages(channel: string): Observable<IMessage[]> {
return collectionData<IMessage>(
query<IMessage, IMessage>(
this.#getCollectionRef(channel),
orderBy('date'),
),
{idField: 'id'},
)
}
}Nu worden de boodschappen, zoals verwacht, getoond in de volgorde waarin ze gepubliceerd zijn.
Natuurlijk zou het aangenaam zijn als we de boodschappen kunnen filteren op basis van inhoud. Bijvoorbeeld, enkel de berichten tonen die geschreven zijn door de huidige gebruiker. Spijtig genoeg krijgen we een foutmelding (in de console) als we een where clause toevoegen aan de methode.
import {
addDoc,
collection,
collectionData,
CollectionReference,
doc,
DocumentReference,
Firestore,
orderBy,
query,
where,
} from '@angular/fire/firestore'
export class DatabaseService {
// Niet relevante code weggelaten.
retrieveMessages(channel: string): Observable<IMessage[]> {
return collectionData<IMessage>(
query<IMessage, IMessage>(
this.#getCollectionRef(channel),
orderBy('date'),
where('user', '==', this.#authService.getUserUid()),
),
{idField: 'id'},
)
}
}In de console krijgen we onderstaande foutmelding te zien.
ERROR FirebaseError: The query requires an index. You can create it here: ...
Als we de orderBy clause verwijderen, werkt de query wel. Het probleem is dat Firestore standaard enkel indexen bouwt voor één kolom. Als je tegelijkertijd op meerdere kolommen wilt filteren, moet er een composite index aangemaakt worden. De link in de foutmelding brengt je naar de Firebase console en bevat de configuratie voor de index, je moet enkel nog op OK (create) drukken. Let op dat je niet te veel indexen bouwt, dit is een kostelijke operatie en vertraagd je database (voor grote hoeveelheden data). Voeg enkel een index toe als je echt moet zoeken op twee of meer kolommen.
Berichten verwijderen
Om berichten te verwijderen kunnen we gebruik maken van de deleteDoc methode. Deze methode is heel eenvoudig en verwacht enkel een referentie naar het document dat verwijderd moet worden als argument. Dankzij de {idField: 'id'} die we in de retrieveMessages methode meegegeven hebben, wordt de id property correct van de Message interface opgevuld. Dit id wordt dan gebruikt om een referentie naar het object te bouwen.
We definiëren een methode deleteMessage in de DatabaseService en gebruiken deze als in de MessageComponent.
import {
addDoc,
collection,
collectionData,
CollectionReference,
deleteDoc,
doc,
DocumentReference,
Firestore,
orderBy,
query,
where,
} from '@angular/fire/firestore'
export class DatabaseService {
// Niet relevante code weggelaten.
async deleteMessage(channel: string, id: string): Promise<void> {
await deleteDoc(this.#getDocumentRef(channel, id))
}
}export class MessageComponent implements OnInit {
// Niet relevante code weggelaten.
async presentActionSheet() {
const actionSheet = await this.#actionSheetCtrl.create({
buttons: [
{
text: 'Delete',
role: 'destructive',
icon: 'trash',
handler: () => this.deleteMessage(),
},
],
})
await actionSheet.present()
}
async deleteMessage(): Promise<void> {
this.#dbService.deleteMessage(this.channel, this.message.id!).then()
}
}Berichten updaten
Om de inhoud van een bericht aan te passen gebruiken we de updateDoc methode, deze methode heeft naast een referentie naar het document dat bijgewerkt moet worden ook de nieuwe data nodig. Omdat het id van een document in de database niet als attribuut van het document bewaard wordt, maar wel als naam van het document, verwijderen we eerst het id uit het bericht dat we naar de database sturen.
import {
addDoc,
collection,
collectionData,
CollectionReference,
deleteDoc,
doc,
DocumentReference,
Firestore,
orderBy,
query,
updateDoc,
where,
} from '@angular/fire/firestore'
export class DatabaseService {
// Niet relevante code weggelaten.
async updateMessage(channel: string, id: string, msg: IMessage): Promise<void> {
const newMessage = {...msg}
delete newMessage.id
await updateDoc(this.#getDocumentRef(channel, id), newMessage)
}
}export class MessageComponent implements OnInit {
// Niet relevante code weggelaten.
@ViewChild(IonTextarea)
messageTextArea: IonTextarea
enableEditing = false
async presentActionSheet() {
const actionSheet = await this.#actionSheetCtrl.create({
buttons: [
{
text: 'Edit',
role: 'destructive',
icon: 'create',
handler: () => {
this.enableEditing = true
setTimeout(() => this.messageTextArea?.setFocus(), 500)
},
},
],
})
await actionSheet.present()
}
async saveEdit(): Promise<void> {
this.enableEditing = false
await this.#dbService.updateMessage(this.channel, this.message.id!, this.message)
}
}<div>
<div *ngIf="skeleton"
[class]="'message ion-margin-horizontal ion-margin-top' + (floatLeft ? ' ion-float-left' : ' ion-float-right')">
<div>
<ion-skeleton-text [animated]="true"></ion-skeleton-text>
<ion-skeleton-text [animated]="true"></ion-skeleton-text>
<ion-skeleton-text [animated]="true"></ion-skeleton-text>
</div>
<div>
<ion-skeleton-text [animated]="true"></ion-skeleton-text>
</div>
</div>
<div *ngIf="!skeleton"
[class]="'message ion-margin' + (floatLeft ? ' ion-float-left' : ' ion-float-right')">
<div>
<ion-textarea [disabled]="!enableEditing" [autoGrow]="true" class="ion-text-wrap" aria-label="message-content"
(focusout)="saveEdit()" [(ngModel)]="message.content" (keydown.enter)="saveEdit()">
</ion-textarea>
</div>
<div>
<div>
<ion-note>{{message.displayName}}</ion-note>
</div>
<div>
<ion-button *ngIf="userId === message.user" fill="clear" (click)="presentActionSheet()">
<ion-icon name="ellipsis-vertical" slot="icon-only"></ion-icon>
</ion-button>
</div>
</div>
</div>
</div>