Les 6: Firebase

Sebastiaan HenauOngeveer 21 minuten

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 startbestandenopen in new window 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/pricingopen in new window.

Voor deze les heb je een gratis Firebase (Google) account nodig, deze kan aangemaakt worden op https://console.firebase.google.com/open in new window. Het aanmaken van een project wijst zichzelf uit.

Figuur 1: Aanmaken van een Firebase Project

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.

Figuur 2: Aanmaken van een Firebase Web Project

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'
    }
};


 
 
 
 
 
 
 
 

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.

Figuur 3: Aanmaken van een keystore om Android apps te ondertekenen

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.

Figuur 4: Android app toevoegen aan Firebase

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.

Figuur 5: google-services.json toevoegen aan een Android project

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.

Figuur 6: Signing config toevoegen aan een Android project

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 paginaopen in new window van de authentication plug-in zijn links te vinden naar guides voor elke provider.

Figuur 7: Activeren van Google en Phone authentication

Firebase & Angular

Om Firebase te gebruiken in een Angular applicatie moeten we een aantal libraries installeren via pnpm. De officiële Firebase SDKopen in new window heeft TypeScript bindings, maar heeft geen goede Angular integratie. Aangezien zowel Angular als Firebase door Google ontwikkeld worden, is er hiervoor een oplossing. Angular Fireopen in new window 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 repositoryopen in new window, deze documentatie is niet altijd heel goed uitgewerkt of duidelijk. Af en toe zal het ook nodig zijn de documentatie van de Firebase JS SDKopen in new window 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 Shakingopen in new window.

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>open in new window 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>

 


 





















 


 


Figuur 8: IonSplitPane in werking

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 Authenticationopen in new window 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
  • Facebook
  • Game Center
  • GitHub
  • Google
  • Microsoft (personal & Azure AD)
  • Play Games
  • Twitter
  • Yahoo
  • Anonymous
  • Email
  • 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 documentatieopen in new window is voor elke provider te vinden welke SDK toegevoegd moet worden (dit is niet noodzakelijk voor elke provider). Om in te loggen via Googleopen in new window moet het bestand variables.gradle aangepast worden. Voor phoneopen in new window 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 klasseopen in new window, 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.

Figuur 9: Documentatie voor de Google Auth Provider
Figuur 10: Documentatie voor de GitHub Auth Provider

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 PhoneAuthProvideropen in new window 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.

Figuur 11: Firefox configureren voor phone auth

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.

Figuur 12: Guard zonder redirect

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.

Figuur 13: Guard met redirect

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;
    }
  }
}
Figuur 14: Configureren van Firestore

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;
}

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.

Figuur 15: Versturen van een bericht

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 queryopen in new window 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.

Figuur 16: Versturen & uitlezen van berichten

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 documentatieopen in new window

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.

Figuur 17: Versturen & uitlezen van berichten, in de juiste volgorde

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));
    }
}











 





 
 
 

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);
    }
}












 





 
 
 


Figuur 18: Eindresultaat

Voorbeeldcode

Volledig uitgewerkte lesvoorbeelden met commentaaropen in new window

Laatst geüpdate:
Bijdragers: Sebastiaan Henau