Les 6: Firebase
Les 6: 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.
Voor deze les zijn er startbestanden voorzien, de UI is zo goed als volledig af, de code hiervoor 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. Het uitgewerkte voorbeeld is onderaan deze les en op Canvas beschikbaar. In de uitgewerkte versie is Firebase nog niet geconfigureerd, noch in het Angular project, noch in het Android project. Je moet dit zelf doen.
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 aanbied 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.
Web configuratie
Voor het webplatform is weinig configuratie nodig, we hoeven enkel API keys te generen en deze toe te voegen aan ons 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.
Waarschuwing
Kopieer onderstaande API keys niet in jouw project, je moet zelf een Firebase project aanmaken. Onderstaande keys zijn niet geldig, 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 configuratie
Om Firebase te kunnen gebruiken in het Android project, moet de app gehandtekend worden. Deze handtekening wordt gecontroleerd bij elk HTTP Request dat naar Firebase gestuurd wordt. Als deze configuratie niet in orde is en je Android project gebruik maakt van Firebase, zal de applicatie niet gecompileerd kunnen worden of onmiddellijk crashen. Om deze handtekening te genereren hebben we Android Studio nodig en de Java Runtime Environment, 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 effectief 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 4.
Genereren van een keystore
Onderstaande video toont hoe de sleutel gegenereerd kan worden met Android Studio.
Info
Er is niets dat je weerhoud 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 en mee geupload worden op Canvas (voor het project). Dit stelt de lector in staat om de applicatie volledig te testen en te debuggen.
Onderstaande video toont dat je een wachtwoord moet ingeven. Gebruik hier een wachtwoord dat je nergens anders gebruikt, dit wachtwoord zal namelijk in plain-tekst bewaard worden in je Android project. Voor je project betekent dit dat dit wachtwoord ook mee geüpload wordt naar Canvas.
Info
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.jks
Het resultaat ziet er ongeveer als volgt uit, de belangrijke lijnen zijn gemarkeerd.
Waarschuwing
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: 3
Hashes 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. Doe je dit niet, dan zul je compilatiefouten krijgen zodra je de authenticatie plug-in installeert.
Waarschuwing
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/google-services.json.

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 via pnpm. 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 hiervoor een oplossing. Angular Fire biedt volledige Firebase ondersteuning in een Angular project.
pnpm add firebase @angular/fire
Gevaar
De Angular dependencies vragen minimaal een versie 6.6.7 van RxJS, deze library heeft versie 1.9.0 van tslib als dependency. De tools die we juist geïnstalleerd hebben (firebase en @angular/fire) hebben minimaal versie 2.5.0 van tslib nodig. Deze conflicten zorgen ervoor dat pnpm op het moment van schrijven versie 1.14.1 gebruikt. Deze versie bevat mist enkele functies waardoor je onderstaande foutmelding krijgt als je het project opstart.
[ng] ./node_modules/.pnpm/rxfire@6.0.3_firebase@9.17.0+rxjs@7.5.7/node_modules/rxfire/firestore/index.esm.js:304:25-38 - Error: export '__spreadArray' (imported as '__spreadArray') was not found in 'tslib' (possible exports: __assign, __asyncDelegator, __asyncGenerator, __asyncValues, __await, __awaiter, __classPrivateFieldGet, __classPrivateFieldSet, __createBinding, __decorate, __exportStar, __extends, __generator, __importDefault, __importStar, __makeTemplateObject, __metadata, __param, __read, __rest, __spread, __spreadArrays, __values)Om het project te compileren kan je onderstaande code onderaan op root niveau toevoegen aan package.json.
{
...
"pnpm": {
"overrides": {
"tslib": "^2.5.0"
}
}
De documentatie voor Angular Fire is te vinden in het git repository, deze documentatie is niet altijd heel goed uitgewerkt of duidelijk. Af en toe zal het ook nodig zijn de documentatie van de Firebase JS SDK te bekijken, sommige features zijn niet beschikbaar in Angular Fire, maar wel in de Firebase SDK. Deze documentatie voor Firebase is wel goed en duidelijk.
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 {}
Waarschuwing
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 v8 van de SDK. 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.
Gebruik enkel import statements waarin het woord compat niet voorkomt. Voor sommige onderdelen van Angular Fire is de documentatie nog niet geüpdatet. Als je zo'n probleem tegen komt, kan je gebruik maken van de Firebase JavaScript SDK documentatie, de namen die hierin gebruikt worden, zijn ook de namen die gebruikt worden in de nieuwe modulaire import statements voor Angular Fire.
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. Ook de andere breakpoints kunnen via het when attribuut gebruikt worden.
Let op, de ion-split-pane component kan slechts één keer gebruikt worden.
<ion-app>
<ion-split-pane contentId="main">
<!-- the side menu -->
<ion-menu contentId="main">
<ion-content *ngIf="(authService.currentUser | async); let user">
<ion-item>
<ion-avatar slot="start">
<img [src]="user?.photoURL ?? placeholder" #avatar (error)="avatar.src = placeholder">
</ion-avatar>
<ion-label>
<ion-list-header>
{{user.displayName}}
</ion-list-header>
<ion-note class="ion-margin-start">
{{user.email || ''}}
</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 nieuwe vensters waarin ingelogd kan worden. Vervolgens wordt er een callback URL gebruikt die de webapplicatie opnieuw laad en de gebruiker inlogt. Dit laatste is het probleem, het openen van de nieuwe vensters 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 toestel afgehandeld en omdat de webview op localhost draait, zal de callback verwijzen naar http://localhost. Hier draait er 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 webmethodes geen gebruik van gebruikersaccounts die al gedefinieerd zijn in het Android systeem. Dit betekend dat de gebruiker zijn/haar Google account wachtwoord moet intypen, niet ideaal.
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 ondersteund deze plug-in volgende providers:
- Apple
- Game Center
- GitHub
- Microsoft (personal & Azure AD)
- Play Games
- Yahoo
- Anonymous
- Phone (enkel op iOS & Android)
- Custom Token
We kunnen deze plug-in installeren met onderstaand npm commando.
pnpm add @capacitor-firebase/authentication
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 systeem. Omdat de app op de native layer werkt kunnen we ook gebruik maken van accounts die al aanwezig zijn op het systeem. Zo moet de gebruiker niet opnieuw inloggen op zijn Google account (op Android), maar wordt de bestaande systeem account gedetecteerd, de gebruiker moet deze enkel nog selecteren.
const config: CapacitorConfig = {
appId: 'be.thomasmore.graduaten.chat',
appName: 'Les 5 Chat',
webDir: 'www',
bundledWebRuntime: false,
plugins: {
// Onderstaande lijn genereert een linting fout
// Hier is niets aan te doen, behalve deze te negeren.
// eslint-disable-next-line @typescript-eslint/naming-convention
FirebaseAuthentication: {
skipNativeAuth: false,
providers: ['phone', 'google.com'],
},
}
};
Om deze wijzigingen over te zetten naar het Android project, voeren we onderstaand commando uit.
pnpm exec cap sync android
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 welke SDK toegevoegd moet worden (dit is niet noodzakelijk voor elke provider). 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 = 32
targetSdkVersion = 32
androidxActivityVersion = '1.4.0'
androidxAppCompatVersion = '1.4.2'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.8.0'
androidxFragmentVersion = '1.4.1'
coreSplashScreenVersion = '1.0.0-rc01'
androidxWebkitVersion = '1.4.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.3'
androidxEspressoCoreVersion = '3.4.0'
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, als gebruiker van de plug-in, 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 en singInWithPhoneNumber methodes van de plug-in, 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, dan zien we dat we de methode credential kunnen gebruiken om een OAuthCredential object te bouwen, dat object kunnen we vervolgens doorgeven aan de signInWithCredential methode uit Angular Fire.
import {Auth, signInWithCredential, signOut} from '@angular/fire/auth';
import {updateProfile, GoogleAuthProvider, PhoneAuthProvider, User} from 'firebase/auth';
export class AuthService {
constructor(public auth: Auth) {}
// Niet relevante code weggelaten.
async signInWithGoogle(): Promise<void> {
// Sign in on the native layer.
const {credential} = await FirebaseAuthentication.signInWithGoogle();
// Sign in on the web layer.
if (Capacitor.isNativePlatform() && credential?.idToken) {
const newCredential = GoogleAuthProvider
.credential(credential?.idToken);
await signInWithCredential(this.auth, newCredential);
}
}
}
Hint
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.


De plug-in ondersteund phone authentication niet voor PWA's, op native platformen kunnen we wel inloggen via een GSM-nummer. Let op, dit is minder veilig dan via Google, een GSM-nummer kan van eigenaar wisselen, en een gestolen, niet beveiligde GSM, is voldoende om toegang te krijgen tot iemands account.
Om in te loggen via een GSM-nummer wordt eerst geverifieerd of de gebruiker al dan niet een robot is, Google gebruikt hiervoor, in eerste instantie, de Google Services, als deze aanwezig zijn op het toestel. Als deze voor de één of andere reden niet gebruikt kunnen worden en de authenticatie niet automatisch kan verlopen, wordt er een captcha gecontroleerd.
Na deze verificatie procedure wordt een verificationId gemaakt en wordt een verificatieCode naar het ingegeven GSM-nummer verstuurd. De documentatie voor de PhoneAuthProvider toont dat we via het verificationId en de verificationCode een OAuthCredential kunnen aanmaken. Hiermee kunnen we dan aanmelden.
import {updateProfile, GoogleAuthProvider, PhoneAuthProvider, User} from 'firebase/auth';
export class AuthService {
// Niet relevante code weggelaten.
#verificationId: string;
async sendPhoneVerificationCode(phoneNumber: string): Promise<void> {
const listener = FirebaseAuthentication.addListener('phoneCodeSent', ({verificationId}) => {
this.#verificationId = verificationId;
listener.remove();
});
await FirebaseAuthentication.signInWithPhoneNumber({phoneNumber});
}
async signInWithPhoneNumber(verificationCode: string): Promise<void> {
if (!this.#verificationId) {
throw new Error(`No valid verificationId found, ensure the sendPhoneVerificationCode method was called first.`);
}
const credential = PhoneAuthProvider.credential(this.#verificationId, verificationCode);
await signInWithCredential(this.auth, credential);
}
}
Natuurlijk moeten we ook bij het uitloggen rekening houden met de verschillende lagen.
async signOut(): Promise<void> {
await FirebaseAuthentication.signOut();
if (Capacitor.isNativePlatform()) {
await signOut(this.auth);
}
}
Waarschuwing
De Android versie van Firefox staat standaard de authenticatie via GSM niet toe. Alle andere geteste browsers doen dit wel. Onderstaande video toont welke instelling je aan moet zetten om toch via de Android versie van Firefox te kunnen authenticeren.
Hint
Phone authentication kan soms heel lang duren, de free tier van Firebase stuurt spijtig genoeg niet altijd onmiddellijk een SMS. Je kan altijd een nummer voor testing toevoegen, zo definieer je op voorrand de verificatiecode en moet je niet wachten op een SMS.
Guards
Angular guards kunnen gebruikt worden om te controleren of een bepaalde route wel geladen mag worden. Er bestaan verschillende soorten guards, zo is er de canActivate guard die bepaald of een route bezocht kan worden en de canLoad guard die controleert of een route wel gedownload mag worden. Dit laatste is bijvoorbeeld nuttig om geen onnodig werk te doen als de gebruiker geen administrator is, dan heeft het tenslotte geen zin om alle admin pagina's in te laden.
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: '',
redirectTo: 'channel/general',
pathMatch: 'full'
},
{
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]
}
];
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 de gebruiker zou doorgestuurd moeten worden naar de login pagina als hij/zij niet ingelogd is.
De AuthGuard module van Angular Fire biedt hier ook een oplossing voor, we kunnen de redirectUnauthorizedTo methode gebruiken, en deze meegeven aan de pipe. Deze methode krijgt een routing array als argument en wordt uitgevoerd als de gebruiker de route niet mag bezoeken.
import {redirectUnauthorizedTo, AuthGuard} from '@angular/fire/auth-guard';
const redirectUnauthorizedToLogin = () => redirectUnauthorizedTo(['login']);
const routes: Routes = [
{
path: '',
redirectTo: 'channel/general',
pathMatch: 'full'
},
{
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: redirectUnauthorizedToLogin}
}
];
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 {
public currentUser: BehaviorSubject<null | User> = new BehaviorSubject<null | User>(null);
#authUnsubscribe: Unsubscribe;
constructor(public auth: Auth, public router: Router) {
this.#authUnsubscribe = this.auth.onAuthStateChanged(user => this.setCurrentUser(user));
}
// Niet relevante code weggelaten.
private async setCurrentUser(user: User | null): Promise<void> {
this.currentUser.next(user);
if (this.currentUser) {
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 {
constructor(public authService: 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, 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 collectie. Firestore kan, net zoals de real-time database, gebruikt worden om wijzigingen onmiddellijk te synchroniseren naar alle verbonden clients. Maar, in tegenstelling tot de real-time database, kan Firestore ook als een klassieke database gebruikt worden. Een database waar queries aan gesteld worden en waar je een snapshot resultaat van terugkrijgt, i.e. geen real-time data.
Naast deze features, kan Firestore ook gebruikt worden om complexe queries te schrijven. Verder kan Firestore ook beveiligd worden, het is mogelijk om enkel geautoriseerde gebruikers toegang te geven, of enkel gebruikers met bepaalde rollen. Dit ligt echter buiten de scope van onze cursus, we zullen niet ingaan op de beveiliging en elke geautoriseerde 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 kunnen opnieuw naar Firebase gaan en daar een paar keer klikken. Firestore kan voor 30 dagen gebruikt worden in "Test Mode". Dit betekent 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. We kunnen onderstaande regels toevoegen, in het "Rules", tabblad. Zo worden alle request van niet ingelogde gebruikers geweigerd en kunnen we verder alles doen wat we willen.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}
Als je Firestore wil gebruiken zonder dat je inloggen wil implementeren, dan kan je dit aanpassen naar:
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. 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 {enableMultiTabIndexedDbPersistence,
getFirestore, provideFirestore} from '@angular/fire/firestore';
@NgModule({
declarations: [AppComponent],
entryComponents: [],
imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule,
// Firebase main import.
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
// Firestore database import
provideFirestore(() => {
const firestore = getFirestore();
// Enable offline persistence.
enableMultiTabIndexedDbPersistence(firestore);
return firestore;
}),
// Firebase authentication import.
provideAuth(() => getAuth())
],
providers: [{provide: RouteReuseStrategy, useClass: IonicRouteStrategy}],
bootstrap: [AppComponent],
})
export class AppModule {}
Waarschuwing
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";
@Injectable({
providedIn: 'root'
})
export class DatabaseService {
constructor(private authService: AuthService, private fireStore: Firestore) {
}
}
Om de referenties op te vragen, maken we gebruik van twee hulpmethodes. De functies die in Firebase beschikbaar zijn om een document of collectie referentie te genereren zijn niet generic, terwijl de functies waarmee een document opgehaald of verwijderd kan worden dit wel zijn. Onze helper methodes zullen bijgevolg generic moeten zijn. Zo kunnen we een referentie naar een collectie van Message of Channel objecten creëren, de Message en Channel interfaces zijn al beschikbaar in de startbestanden en zien er als volgt uit.
export interface Message {
content: string;
user: string;
profile: string;
displayName: string;
id?: string;
date: number;
}
export interface Channel {
name: string;
isPublicChannel: boolean;
users?: string[];
id?: string;
owner?: string;
}
De eerste helper methode krijgt de naam van een collectie als argument en geeft een referentie van het type CollectionReference<T> terug.
import {
collection,
CollectionReference,
Firestore
} from "@angular/fire/firestore";
export class DatabaseService {
// Niet relevante code weggelaten.
#getCollectionRef<T>(collectionName: string): CollectionReference<T> {
return collection(this.fireStore, collectionName) as CollectionReference<T>;
}
}
We kunnen aan deze methode het type van het soort objecten in de collectie meegegeven, tussen < en >. Onderstaande oproep geeft dus de collectie 'general' terug, die bestaat uit Message objecten.
this.#getCollectionRef<Message>('general')
Hint
Alhoewel we hier een collectie hebben zonder slashes, is het perfect mogelijk om geneste collectie te gebruiken.
this.#getCollectionRef<Message>('general/subchannel/subsubchannel')
We kunnen iets gelijkaardigs doen voor de referentie naar een bepaald 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>(collectionName: string, id: string): DocumentReference<T> {
return doc(this.fireStore, `${collectionName}/${id}`) as DocumentReference<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 {
collection,
CollectionReference,
Firestore,
doc,
DocumentReference,
addDoc
} from "@angular/fire/firestore";
export class DatabaseService {
// Niet relevante code weggelaten.
async sendMessage(channel: string, message: string): Promise<void> {
const newMessage = {
content: message,
user: this.authService.getUserUID(),
displayName: this.authService.getDisplayName(),
profile: this.authService.getProfilePic(),
date: Date.now()
};
await addDoc(
this.getCollectionRef<Message>(channel),
newMessage
);
}
}
We kunnen deze methode vervolgens gebruiken 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';
import {Message} from '../types/message';
export class ChannelPage implements OnInit {
// Niet relevante code weggelaten.
channelName = 'General';
newMessage = '';
constructor(private dbService: DatabaseService) {
}
sendMessage(): void {
this.dbService.sendMessage(this.channelName, this.newMessage);
this.newMessage = '';
}
}
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 gif.
Uitlezen van berichten
Om een document op te halen kan de docData methode gebruikt worden, om een collectie van verschillende documenten op te halen kan de collectionData methode gebruikt worden. De collectionData methode verwacht een query argument, dit argument bevat de referentie naar een collection. Deze methode die we schrijven geeft een Observable van Message objecten terug. De collectioData methode geeft een Observable terug, via de generische parameter kunnen we aangeven dat het een Observable<Message[]> moet zijn, net zoals bij de HTTP Request in de vorige les. Firebase stuurt via deze Observable elke wijziging in de database door naar alle geabonneerde klassen. De data wordt dus in real-time doorgestuurd.
De idField waarde die op lijn 20 meegegeven wordt, zorgt ervoor dat de id property in de Message objecten correct opgevuld wordt.
import {
collection,
CollectionReference,
Firestore,
doc,
DocumentReference,
addDoc,
collectionData,
query
} from "@angular/fire/firestore";
export class DatabaseService {
// Niet relevante code weggelaten.
retrieveMessages(channel: string): Observable<Message[]> {
return collectionData<Message>(
query<Message>(
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.
We kunnen, in de constructor van de ChannelPage component, de methode retrieveMessages gebruiken om de instantievariabele messages te initialiseren.
export class ChannelPage implements OnInit {
channelName = 'General';
messagesObservable: Observable<Message[]> = from([]);
constructor(private dbService: DatabaseService) {
this.messagesObservable = 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.
We kunnen de query methode in de retrieveMessages methode uitbreiden met QueryConstraints, dit zijn methodes zoals orderBy, where, ... De volledige lijst is te raadplegen in de firebase documentatie
import {
collection,
CollectionReference,
Firestore,
doc,
DocumentReference,
addDoc,
getDocs,
query,
orderBy
} from "@angular/fire/firestore";
export class DatabaseService {
// Niet relevante code weggelaten.
retrieveMessages(channel: string): Observable<Message[]> {
return collectionData<Message>(
query<Message>(
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 {
collection,
CollectionReference,
Firestore,
doc,
DocumentReference,
addDoc,
getDocs,
query,
orderBy,
where
} from "@angular/fire/firestore";
export class DatabaseService {
// Niet relevante code weggelaten.
retrieveMessages(channel: string): Observable<Message[]> {
return collectionData<Message>(
query<Message>(
this.#getCollectionRef(channel),
orderBy('date'),
where('user', '==', this.authService.getUserUID())
),
{idField: 'id'}
);
}
}
In de console krijgen we onderstaande foutmelding te zien.
ERROR Error: Uncaught (in promise): FirebaseError: [code=failed-precondition]: 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 op meerdere kolommen wil filteren, moet er een composite index aangemaakt worden. Als je op de link in de console drukt, en even wacht tot de index gebouwd is, zal de query wel werken. Let op dat je niet te veel indexen bouwt, dit is een kostelijke operatie en vertraagd je database. 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, we moeten enkel een referentie naar het document dat verwijdert moet worden meegeven. Dankzij de {idField: 'id'} die we in de retrieveMessages methode meegegeven hebben, wordt de id property correct van de Message interface opgevuld. We kunnen dit id dan gebruiken om een referentie naar het object te krijgen.
We definiëren een methode deleteMessage in de DatabaseService, in de MessageComponent definiëren we een methode die de boodschap bewaard als de gebruiker de focus uit het edit veld haalt, of op enter drukt.
import {
collection,
CollectionReference,
Firestore,
doc,
DocumentReference,
addDoc,
getDocs,
query,
orderBy,
where,
deleteDoc
} 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(): Promise<void> {
const actionSheet = await this.actionSheetCtrl.create({
buttons: [
{
text: 'Delete',
role: 'destructive',
icon: 'trash',
handler: () => this.deleteMessage()
},
],
});
await actionSheet.present();
}
async deleteMessage(): Promise<void> {
if (!this.message?.id) {
return;
}
await this.dbService.deleteMessage(this.channel, this.message.id);
}
}
Berichten updaten
Om een bericht te updaten, kunnen we gebruik maken van updateDoc methode, deze methode heeft naast een referentie naar het document dat bijgewerkt moet worden ook het nieuwe object nodig. Omdat het id van een document in de database niet als attribuut van document bewaard wordt, maar buiten het document, verwijderen we eerst het id uit het bericht dat we naar de database sturen.
import {
collection,
CollectionReference,
Firestore,
doc,
DocumentReference,
addDoc,
getDocs,
query,
orderBy,
where,
deleteDoc,
updateDoc
} from "@angular/fire/firestore";
export class DatabaseService {
// Niet relevante code weggelaten.
async updateMessage(channel: string, id: string, msg: Message): Promise<void> {
delete msg.id;
await updateDoc(this.#getDocumentRef(channel, id), msg);
}
}
export class MessageComponent implements OnInit {
// Niet relevante code weggelaten.
@ViewChild(IonTextarea)
messageTextArea: IonTextarea;
enableEditing = false;
async presentActionSheet() {
const actionSheet = await this.actionSheetCtrl.create({
buttons: [
// Delete button weggelaten.
{
text: 'Edit',
role: 'destructive',
icon: 'create',
handler: () => {
this.enableEditing = true;
setTimeout(() => this.messageTextArea.setFocus(), 500);
}
}
],
});
await actionSheet.present();
}
async saveEdit(): Promise<void> {
if (!this.message?.id) {
return;
}
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></ion-skeleton-text>
<ion-skeleton-text animated></ion-skeleton-text>
<ion-skeleton-text animated></ion-skeleton-text>
</div>
<div>
<ion-skeleton-text animated></ion-skeleton-text>
</div>
</div>
<div color="light" *ngIf="!skeleton"
[class]="'message ion-margin' + (floatLeft ? ' ion-float-left' : ' ion-float-right')">
<div>
<ion-textarea [disabled]="!enableEditing" [autoGrow]="true" class="ion-text-wrap"
(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>