Skip to content

Commit 5f7e7a4

Browse files
committed
Implement Firebase Cloud Messaging support
1 parent 797aa32 commit 5f7e7a4

File tree

5 files changed

+144
-2
lines changed

5 files changed

+144
-2
lines changed

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ apscheduler==3.6.0
22
certifi==2018.11.29
33
chardet==3.0.4
44
Click==7.0
5+
firebase-admin==5.4.0
56
Flask==1.0.2
67
Flask-SQLAlchemy==2.3.2
78
Flask-Migrate==2.1.1

timeline_sync/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from flask import Flask, request
22
from werkzeug.middleware.proxy_fix import ProxyFix
33
from rws_common import honeycomb
4+
import firebase_admin
45

56
from .settings import config
67
from .api import init_api
@@ -16,6 +17,8 @@
1617
init_app(app)
1718
init_api(app) # Includes both private (timeline-sync) and public (timeline-api) APIs
1819

20+
default_app = firebase_admin.initialize_app()
21+
1922
@app.route('/heartbeat')
2023
@app.route('/timeline-sync/heartbeat')
2124
def heartbeat():

timeline_sync/api.py

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import uuid
44
import requests
55
from .models import db, SandboxToken, TimelinePin, UserTimeline, TimelineTopic, TimelineTopicSubscription, AppGlance
6-
from .utils import get_uid, api_error, pin_valid, glance_valid
6+
from .utils import get_uid, api_error, pin_valid, glance_valid, send_fcm_message, send_fcm_message_to_topics, subscribe_to_fcm_topic, unsubscribe_from_fcm_topic
77
from .settings import config
88

99
import beeline
@@ -79,6 +79,33 @@ def sync():
7979
}
8080
return jsonify(result)
8181

82+
@api.route('/user/fcm_token/<token>')
83+
def fcm_token():
84+
user_id = get_uid()
85+
86+
if request.method == 'PUT':
87+
fcm_token_json = request.json
88+
89+
fcm_token = FcmToken.query.filter_by(user_id=user_id, token=token).one_or_none()
90+
if fcm_token is None:
91+
fcm_token = FcmToken.from_json(fcm_token_json, token, user_id)
92+
if fcm_token is None:
93+
return api_error(400)
94+
95+
db.session.add(fcm_token)
96+
db.session.commit()
97+
# TODO: Also subscribe to user's topics
98+
# TODO: Resend UserTimeline for the pins about the topic
99+
100+
elif request.method == 'DELETE':
101+
fcm_token = FcmToken.query.filter_by(user_id=user_id, token=token).first_or_404()
102+
fcm_token.delete()
103+
104+
db.session.commit()
105+
# TODO: Also unsubscribe from user's topics
106+
# TODO: Send UserTimeline to delete pins the user no longer has a subscription for
107+
return 'OK'
108+
82109

83110
@api.route('/user/pins/<pin_id>', methods=['PUT', 'DELETE'])
84111
def user_pin(pin_id):
@@ -107,6 +134,8 @@ def user_pin(pin_id):
107134
db.session.add(pin)
108135
db.session.add(user_timeline)
109136
db.session.commit()
137+
138+
send_fcm_message(user_id, { 'type': 'timeline.pin.create' })
110139
else: # update pin
111140
try:
112141
pin.update_from_json(pin_json)
@@ -122,6 +151,8 @@ def user_pin(pin_id):
122151
db.session.add(pin)
123152
db.session.add(user_timeline)
124153
db.session.commit()
154+
155+
send_fcm_message(user_id, { 'type': 'timeline.pin.create' })
125156
except (KeyError, ValueError):
126157
beeline.add_context_field('timeline.failure.cause', 'update_pin')
127158
return api_error(400)
@@ -138,6 +169,8 @@ def user_pin(pin_id):
138169
pin=pin)
139170
db.session.add(user_timeline)
140171
db.session.commit()
172+
173+
send_fcm_message(user_id, { 'type': 'timeline.pin.delete' })
141174
return 'OK'
142175

143176

@@ -152,7 +185,6 @@ def get_app_info(timeline_token):
152185
return app_info['app_uuid'], f"uuid:{app_info['app_uuid']}"
153186

154187

155-
156188
@api.route('/shared/pins/<pin_id>', methods=['PUT', 'DELETE'])
157189
def shared_pin(pin_id):
158190
try:
@@ -198,6 +230,8 @@ def shared_pin(pin_id):
198230
db.session.add(user_timeline)
199231

200232
db.session.commit()
233+
234+
send_fcm_message_to_topics(topics, { 'type': 'timeline.pin.create' })
201235
else: # update pin
202236
try:
203237
pin.update_from_json(pin_json)
@@ -217,12 +251,15 @@ def shared_pin(pin_id):
217251
db.session.add(user_timeline)
218252

219253
db.session.commit()
254+
255+
send_fcm_message_to_topics(topics, { 'type': 'timeline.pin.create' })
220256
except (KeyError, ValueError):
221257
beeline.add_context_field('timeline.failure.cause', 'update_pin')
222258
return api_error(400)
223259

224260
elif request.method == 'DELETE':
225261
pin = TimelinePin.query.filter_by(app_uuid=app_uuid, user_id=None, id=pin_id).first_or_404()
262+
topics = pin.topics
226263

227264
# No need to post even old create events, since nobody will render
228265
# them, after all.
@@ -236,6 +273,9 @@ def shared_pin(pin_id):
236273
db.session.add(user_timeline)
237274

238275
db.session.commit()
276+
277+
send_fcm_message_to_topics(topics, { 'type': 'timeline.pin.delete' })
278+
239279
return 'OK'
240280

241281

@@ -276,11 +316,17 @@ def user_subscriptions_manage(topic_string):
276316

277317
db.session.commit()
278318

319+
subscribe_to_fcm_topic(user_id, topic)
320+
send_fcm_message(user_id, { 'type': 'timeline.topic.subscription' })
321+
279322
elif request.method == 'DELETE':
280323
TimelineTopicSubscription.query.filter_by(user_id=user_id, topic=topic).delete()
281324

282325
db.session.commit()
283326

327+
unsubscribe_from_fcm_topic(user_id, topic)
328+
send_fcm_message(user_id, { 'type': 'timeline.topic.unsubscription' })
329+
284330
return 'OK'
285331

286332

@@ -305,7 +351,11 @@ def user_app_glance():
305351
return api_error(400)
306352

307353
db.session.add(glance)
354+
308355
db.session.commit()
356+
357+
send_fcm_message(user_id, { 'type': 'appglance.slice.create' })
358+
309359
return 'OK'
310360

311361

timeline_sync/models.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,28 @@ class SandboxToken(db.Model):
2020

2121
db.Index('sandbox_token_uid_appuuid_index', SandboxToken.user_id, SandboxToken.app_uuid, unique=True)
2222

23+
class FcmToken(db.Model):
24+
__tablename__ = 'fcm_tokens'
25+
token = db.Column(db.String, primary_key=True)
26+
user_id = db.Column(db.Integer)
27+
device_id = db.Column(db.String)
28+
platform = db.Column(db.String)
29+
30+
@classmethod
31+
def from_json(cls, fcm_token_json, token, user_id):
32+
try:
33+
fcm_token = cls(
34+
token=token,
35+
device_id=fcm_token_json['device_id'],
36+
platform=fcm_token_json['platform'],
37+
user_id=user_id,
38+
)
39+
return fcm_token
40+
except (KeyError, ValueError):
41+
return None
42+
43+
44+
db.Index('fcm_token_uid_token_index', FcmToken.user_id, FcmToken.token, unique=True)
2345

2446
class TimelinePin(db.Model):
2547
__tablename__ = 'timeline_pins'

timeline_sync/utils.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from flask import request, abort, jsonify
33
from .settings import config
44
import datetime
5+
import firebase_admin
56

67
import beeline
78

@@ -122,3 +123,68 @@ def glance_valid(glance_json):
122123
return False
123124
return True
124125

126+
127+
def send_fcm_message(user_id, data):
128+
if user_id is None:
129+
raise ValueError
130+
131+
fcm_tokens = db.session.query(FcmToken).filter_by(user_id=user_id)
132+
tokens = [fcm_token.token for fcm_token in fcm_tokens]
133+
134+
message = firebase_admin.messaging.Message(
135+
data=data,
136+
tokens=tokens,
137+
)
138+
139+
response = firebase_admin.messaging.send_each_for_multicast(message)
140+
141+
if response.failure_count > 0:
142+
responses = response.responses
143+
for idx, resp in enumerate(responses):
144+
if not resp.success:
145+
FcmToken.query.filter_by(user_id=user_id, token=tokens[idx]).delete()
146+
147+
148+
def send_fcm_message_to_topics(topics, data):
149+
condition = ' || '.join([f"'{str(topic.id)}' in topics" for topic in topics])
150+
151+
message = firebase_admin.messaging.Message(
152+
data=data,
153+
condition=condition,
154+
)
155+
156+
response = firebase_admin.messaging.send(message)
157+
158+
if not response.success:
159+
return api_error(400)
160+
161+
162+
def subscribe_to_fcm_topic(user_id, topic):
163+
if user_id is None:
164+
raise ValueError
165+
166+
fcm_tokens = db.session.query(FcmToken).filter_by(user_id=user_id)
167+
tokens = [fcm_token.token for fcm_token in fcm_tokens]
168+
169+
response = firebase_admin.messaging.subscribe_to_topic(tokens, str(topic.id))
170+
171+
if response.failure_count > 0:
172+
responses = response.responses
173+
for idx, resp in enumerate(responses):
174+
if not resp.success:
175+
FcmToken.query.filter_by(user_id=user_id, token=tokens[idx]).delete()
176+
177+
def unsubscribe_from_fcm_topic(user_id, topic):
178+
if user_id is None:
179+
raise ValueError
180+
181+
fcm_tokens = db.session.query(FcmToken).filter_by(user_id=user_id)
182+
tokens = [fcm_token.token for fcm_token in fcm_tokens]
183+
184+
response = firebase_admin.messaging.unsubscribe_from_topic(tokens, str(topic.id))
185+
186+
if response.failure_count > 0:
187+
responses = response.responses
188+
for idx, resp in enumerate(responses):
189+
if not resp.success:
190+
FcmToken.query.filter_by(user_id=user_id, token=tokens[idx]).delete()

0 commit comments

Comments
 (0)