Skip to content

Commit

Permalink
feat(v2 backend): update FCM token directly in Firestore (#1981)
Browse files Browse the repository at this point in the history
Before this PR, we would update the client's FCM token by writing to the putClientSettings HTTPS endpoint. With this PR, we remove that intermediate API layer and write directly to Firestore.

We don't currently use the FCM token, so the main purposes of this PR are to...
* Demonstrate how we'll perform direct Firestore access.
* Demonstrate how to configure the app to use the local Firebase (Firestore) emulator.
* Prepare the application for later use of the FCM token.

Closes #1973
  • Loading branch information
rjhuijsman authored Apr 14, 2021
1 parent 47c3334 commit 51b7330
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 88 deletions.
22 changes: 22 additions & 0 deletions client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,25 @@ For example to access the `staging` server (the default), the files are:
Android: client/android/app/src/<flavor>/google-services.json
iOS: client/ios/config/<flavor>/GoogleService-Info.plist
```

##### Using the Firebase Emulators

If you'd like to test your app without using a "real" Firebase project, you can use the Firebase Local Emulator Suite as follows.

First, start your local emulators by navigating to your `server/functions` directory and running:

firebase emulators:start --project=dev

Then, in your `client` directory, update your `main.dart` as follows:

const USE_FIREBASE_LOCAL_EMULATORS = true;

When working with the iOS client, temporarily disable transport security [as documented here](https://firebase.flutter.dev/docs/installation/ios/#enabling-use-of-firebase-emulator-suite).

Then, run your application in its `hack` flavor:

flutter run --flavor=hack

Your logs will confirm that you are using the local emulators:

I/flutter (13491): Will use local 🔥🔥 Firebase 🔥🔥 emulator
32 changes: 18 additions & 14 deletions client/lib/api/who_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,36 @@ import 'dart:io' as io;

import 'package:who_app/proto/api/who/who.pb.dart';

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_performance/firebase_performance.dart';

const CLIENT_COLLECTION = 'Client';

class WhoService {
final String serviceUrl;
final FirebaseFirestore firestore;

WhoService({@required String endpoint}) : serviceUrl = endpoint;
WhoService({@required String endpoint})
: serviceUrl = endpoint,
firestore = FirebaseFirestore.instance;

final _MetricHttpClient http = _MetricHttpClient(
Client(),
);

/// Put Client Settings
Future<bool> putClientSettings({String token, String isoCountryCode}) async {
final headers = await _getHeaders();
final req = PutClientSettingsRequest.create();
if (token != null) {
req.token = token;
} else {
req.clearToken();
}
req.isoCountryCode = isoCountryCode;
final postBody = jsonEncode(req.toProto3Json());
final url = '$serviceUrl/putClientSettings';
final response = await http.post(url, headers: headers, body: postBody);
if (response.statusCode != 200) {
throw Exception('Error status code: ${response.statusCode}');
var clientId = await UserPreferences().getClientUuid();
try {
await firestore.collection(CLIENT_COLLECTION).doc(clientId).set({
'uuid': clientId,
'token': token,
'disableNotifications': token == null || token.isEmpty,
'platform': _platform,
'isoCountryCode': isoCountryCode
});
} catch (e) {
debugPrint('Failed to update FCM token in Firestore: $e');
}
return true;
}
Expand Down
17 changes: 17 additions & 0 deletions client/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'dart:async';

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_performance/firebase_performance.dart';
import 'package:flutter/material.dart';
import 'package:firebase_analytics/firebase_analytics.dart';
Expand Down Expand Up @@ -37,6 +38,10 @@ PackageInfo _packageInfo;

PackageInfo get packageInfo => _packageInfo;

// ATTENTION: never check this in as 'true'! Always set back to 'false' before
// sending out your PR. This is verified by `test/firebase_test.dart`.
const USE_FIREBASE_LOCAL_EMULATORS = false;

void main() async {
await mainImpl(routes: Routes.map);
}
Expand All @@ -51,6 +56,18 @@ void mainImpl({@required Map<String, WidgetBuilder> routes}) async {
}

var app = await Firebase.initializeApp();

if (USE_FIREBASE_LOCAL_EMULATORS) {
debugPrint('Will use local 🔥🔥 Firebase 🔥🔥 emulator');
// Switch Firebase host based on platform, since iOS and Android
// use different ways of contacting localhost.
var host = defaultTargetPlatform == TargetPlatform.android
? '10.0.2.2:8080'
: 'localhost:8080';
FirebaseFirestore.instance.settings =
Settings(host: host, sslEnabled: false);
}

var projectId = app.options.projectId;
print('Firebase ProjectID: $projectId');
var endpoint = Endpoint(projectId);
Expand Down
45 changes: 40 additions & 5 deletions client/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0-nullsafety.1"
cloud_firestore:
dependency: "direct main"
description:
name: cloud_firestore
url: "https://pub.dartlang.org"
source: hosted
version: "0.14.4"
cloud_firestore_platform_interface:
dependency: transitive
description:
name: cloud_firestore_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1"
cloud_firestore_web:
dependency: transitive
description:
name: cloud_firestore_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.1+2"
code_builder:
dependency: transitive
description:
Expand Down Expand Up @@ -252,7 +273,7 @@ packages:
name: firebase_analytics
url: "https://pub.dartlang.org"
source: hosted
version: "6.0.2"
version: "6.3.0"
firebase_analytics_platform_interface:
dependency: transitive
description:
Expand Down Expand Up @@ -294,28 +315,35 @@ packages:
name: firebase_crashlytics
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.3"
version: "0.2.4"
firebase_crashlytics_platform_interface:
dependency: transitive
description:
name: firebase_crashlytics_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.3"
version: "1.1.4"
firebase_messaging:
dependency: "direct main"
description:
name: firebase_messaging
url: "https://pub.dartlang.org"
source: hosted
version: "8.0.0-dev.8"
version: "8.0.0-dev.11"
firebase_messaging_platform_interface:
dependency: transitive
description:
name: firebase_messaging_platform_interface
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0-dev.5"
version: "1.0.0-dev.7"
firebase_messaging_web:
dependency: transitive
description:
name: firebase_messaging_web
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0-dev.2"
firebase_performance:
dependency: "direct main"
description:
Expand Down Expand Up @@ -693,6 +721,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
service_worker:
dependency: transitive
description:
name: service_worker
url: "https://pub.dartlang.org"
source: hosted
version: "0.2.4"
share:
dependency: "direct main"
description:
Expand Down
1 change: 1 addition & 0 deletions client/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ dependencies:
sdk: flutter

auto_size_text: 2.1.0
cloud_firestore: 0.14.4
connectivity: ^2.0.0
cupertino_icons: ^0.1.3
expressions: 0.1.5
Expand Down
14 changes: 14 additions & 0 deletions client/test/firebase_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:who_app/main.dart';

void main() {
test(
'Firebase Emulator usage has been disabled',
() {
expect(
USE_FIREBASE_LOCAL_EMULATORS,
false,
);
},
);
}
17 changes: 14 additions & 3 deletions server/firestore.rules
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Currently: do not allow any direct reads or writes.
// TODO: this will change as we update the client to do direct reads and writes.
// Default: do not allow any direct reads or writes unless otherwise specified.
match /{document=**} {
allow read, write: if false;
}

// Allow writes to the "Clients" collection, permitting users to set their FCM
// tokens.
//
// If we were concerned about abuse, we could add constraints around what the user
// needs to be writing to be allowed to write. Fortunately since this is write-only,
// and since the WHO client ID is a long random string that can't be guessed by others
// (preventing others from overwriting your settings) there seems to be little risk
// of abuse, and we can suffice with a simple rule.
match /Client/{who_client_id} {
allow write: if true;
}
}
}
}
16 changes: 13 additions & 3 deletions server/functions/src/firestore_rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ after(() => {
});

describe("Firebase Rules", () => {
it("Should not allow direct access anywhere", async () => {
it("Should not allow access in random places", async () => {
// Random location in the database.
await firebase.assertFails(
app
Expand All @@ -45,14 +45,24 @@ describe("Firebase Rules", () => {
.doc("nonexistent-doc")
.get()
);
// The Client collection.
});
it("Should only allow write access to the Client collection", async () => {
await firebase.assertFails(
app
.firestore()
.collection("Client")
.doc("00000000-0000-0000-0000-000000000000")
.get()
);
// Add tests for real-life collections and documents here as we add them to Firestore.
await firebase.assertSucceeds(
app
.firestore()
.collection("Client")
.doc("00000000-0000-0000-0000-000000000000")
.set({
foo: "bar",
})
);
});
// Add further tests for real-life collections and documents here as we add them to Firestore.
});
63 changes: 0 additions & 63 deletions server/functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,69 +39,6 @@ export const getCaseStats = functions
response.status(200).json(data);
});

// Implementation of the v1 API"s `putClientSettings` method.
// TODO: replace with direct Firestore acccess from the client.
export const putClientSettings = functions
.region(SERVING_REGION)
.https.onRequest((request, response) => {
const whoClientId = request.header("Who-Client-ID");
if (whoClientId === undefined || whoClientId == null) {
response.status(400).send("Missing Who-Client-ID header");
return;
}
const whoPlatform = request.header("Who-Platform");
if (whoPlatform === undefined || whoPlatform == null) {
response.status(400).send("Missing Who-Platform header");
return;
}
let platform = Platform.WEB;
if (whoPlatform == Platform[Platform.ANDROID]) {
platform = Platform.ANDROID;
} else if (whoPlatform == Platform[Platform.IOS]) {
platform = Platform.IOS;
}

if (request.method != "POST") {
response.status(400).send("Call must be POST request");
return;
}
let isoCountryCode = request.body["isoCountryCode"];
if (isoCountryCode === undefined || isoCountryCode == null) {
isoCountryCode = "";
} else if (
// Don"t even run a regex on a very long string.
isoCountryCode.length != 2 ||
!isoCountryCode.match(COUNTRY_CODE)
) {
response.status(400).send("Invalid isoCountryCode");
return;
}
let fcmToken = request.body["token"];
if (fcmToken === undefined || fcmToken == "null") {
fcmToken = "";
}
if (fcmToken.length > FCM_TOKEN_MAX_LENGTH) {
response.status(400).send("Invalid FCM Token");
return;
}
const disableNotifications = fcmToken.length == 0;

const client = {
uuid: whoClientId,
token: fcmToken,
disableNotifcations: disableNotifications,
platform: platform,
isoCountryCode: isoCountryCode,
subscribedTopics: [], // TODO: fill in.
} as Client;

// This update will trigger the `clientSettingsUpdated` method below,
// which will actually register (or deregister) the client for notifications.
db.collection("Clients").doc(whoClientId).set(client);

response.status(200).send({});
});

// Method that runs when client settings have been updated, and will make those
// updated settings take effect.
export const clientSettingsUpdated = functions
Expand Down

0 comments on commit 51b7330

Please sign in to comment.