Pedro Pombeiro 5a69b4198e
Update PNs to use data-only messaging, and only encode/decode data values. Fixes #6772
Fix navigation to chat when PN is tapped while signed off. Fixes #3488

Anonymize PN pubkeys. Part of #6772
2019-01-17 19:23:55 +02:00

367 lines
15 KiB
Clojure

(ns status-im.notifications.core
(:require [goog.object :as object]
[re-frame.core :as re-frame]
[status-im.react-native.js-dependencies :as rn]
[status-im.js-dependencies :as dependencies]
[taoensso.timbre :as log]
[status-im.i18n :as i18n]
[status-im.accounts.db :as accounts.db]
[status-im.chat.models :as chat-model]
[status-im.utils.platform :as platform]
[status-im.utils.fx :as fx]
[status-im.utils.utils :as utils]))
;; Work in progress namespace responsible for push notifications and interacting
;; with Firebase Cloud Messaging.
(def ^:private pn-message-id-hash-length 10)
(def ^:private pn-pubkey-hash-length 10)
(def ^:private pn-pubkey-length 132)
(when-not platform/desktop?
(def firebase (object/get rn/react-native-firebase "default")))
;; NOTE: Only need to explicitly request permissions on iOS.
(defn request-permissions []
(if platform/desktop?
(re-frame/dispatch [:notifications.callback/request-notifications-permissions-granted {}])
(-> (.requestPermission (.messaging firebase))
(.then
(fn [_]
(log/debug "notifications-granted")
(re-frame/dispatch [:notifications.callback/request-notifications-permissions-granted {}]))
(fn [_]
(log/debug "notifications-denied")
(re-frame/dispatch [:notifications.callback/request-notifications-permissions-denied {}]))))))
(defn valid-notification-payload?
[{:keys [from to]}]
(and from to
(or
;; is it full pubkey?
(and (= (.-length from) pn-pubkey-length)
(= (.-length to) pn-pubkey-length))
;; partially deanonymized
(and (= (.-length from) pn-pubkey-hash-length)
(= (.-length to) pn-pubkey-length))
;; or is it an anonymized pubkey hash (v2 payload)?
(and (= (.-length from) pn-pubkey-hash-length)
(= (.-length to) pn-pubkey-hash-length)))))
(defn sha3 [s]
(.sha3 dependencies/Web3.prototype s))
(defn anonymize-pubkey
[pubkey]
"Anonymize a public key, if needed, by hashing it and taking the first 4 bytes"
(if (= (count pubkey) pn-pubkey-hash-length)
pubkey
(apply str (take pn-pubkey-hash-length (sha3 pubkey)))))
(defn encode-notification-payload
[{:keys [from to id] :as payload}]
(if (valid-notification-payload? payload)
{:msg-v2 (js/JSON.stringify #js {:from (anonymize-pubkey from)
:to (anonymize-pubkey to)
:id (apply str (take pn-message-id-hash-length id))})}
(throw (str "Invalid push notification payload" payload))))
(when platform/desktop?
(defn handle-initial-push-notification [] ())) ;; no-op
(when-not platform/desktop?
(def channel-id "status-im")
(def channel-name "Status")
(def sound-name "message.wav")
(def group-id "im.status.ethereum.MESSAGE")
(def icon "ic_stat_status_notification")
(defn- hash->pubkey [hash accounts]
(:public-key
(first
(filter #(= (anonymize-pubkey (:public-key %)) hash)
(vals accounts)))))
(defn lookup-contact-pubkey-from-hash
[{:keys [db] :as cofx} contact-pubkey-or-hash]
"Tries to deanonymize a given contact pubkey hash by looking up the
full pubkey (if db is unlocked) in :contacts/contacts.
Returns original value if not a hash (e.g. already a public key)."
(if (and contact-pubkey-or-hash
(= (count contact-pubkey-or-hash) pn-pubkey-hash-length))
(if-let
[account-pubkey (hash->pubkey contact-pubkey-or-hash
(:accounts/accounts db))]
account-pubkey
(if (accounts.db/logged-in? cofx)
;; TODO: for simplicity we're doing a linear lookup of the contacts,
;; but we might want to build a map of hashed pubkeys to pubkeys
;; for this purpose
(hash->pubkey contact-pubkey-or-hash (:contacts/contacts db))
(do
(log/warn "failed to lookup contact from hash, not logged in")
contact-pubkey-or-hash)))
contact-pubkey-or-hash))
(defn parse-notification-v1-payload [msg-json]
(let [msg (js/JSON.parse msg-json)]
{:from (object/get msg "from")
:to (object/get msg "to")}))
(defn parse-notification-v2-payload [msg-v2-json]
(let [msg (js/JSON.parse msg-v2-json)]
{:from (object/get msg "from")
:to (object/get msg "to")
:id (object/get msg "id")}))
(defn decode-notification-payload [message-js]
;; message-js.-data is Notification.data():
;; https://github.com/invertase/react-native-firebase/blob/adcbeac3d11585dd63922ef178ff6fd886d5aa9b/src/modules/notifications/Notification.js#L79
(let [data-js (.. message-js -data)
msg-v2-json (object/get data-js "msg-v2")]
(try
(let [payload (if msg-v2-json
(parse-notification-v2-payload msg-v2-json)
(parse-notification-v1-payload (object/get data-js "msg")))]
(if (valid-notification-payload? payload)
payload
(log/warn "failed to retrieve notification payload from"
(js/JSON.stringify data-js))))
(catch :default e
(log/debug "failed to parse" (js/JSON.stringify data-js)
"exception:" e)))))
(defn rehydrate-payload
[cofx {:keys [from to id] :as decoded-payload}]
"Takes a payload with hashed pubkeys and returns a payload with the real
(matched) pubkeys"
{:from (lookup-contact-pubkey-from-hash cofx from)
:to (lookup-contact-pubkey-from-hash cofx to)
;; TODO: Rehydrate message id
:id id})
(defn- build-notification [{:keys [title body decoded-payload]}]
(let [native-notification
(clj->js
(merge
{:title title
:body body
:data (clj->js (encode-notification-payload decoded-payload))
:sound sound-name}
(when-let [msg-id (:id decoded-payload)]
;; We must prefix the notification ID, otherwise it will
;; cause a crash in iOS
{:notificationId (str "hash:" msg-id)})))]
(firebase.notifications.Notification.
native-notification (.notifications firebase))))
(defn display-notification [{:keys [title body] :as params}]
(let [notification (build-notification params)]
(when platform/android?
(.. notification
(-android.setChannelId channel-id)
(-android.setAutoCancel true)
(-android.setPriority firebase.notifications.Android.Priority.High)
(-android.setCategory firebase.notifications.Android.Category.Message)
(-android.setGroup group-id)
(-android.setSmallIcon icon)))
(.. firebase
notifications
(displayNotification notification)
(then #(log/debug "Display Notification" title body))
(catch (fn [error]
(log/debug "Display Notification error" title body error))))))
(defn get-fcm-token []
(-> (.getToken (.messaging firebase))
(.then (fn [x]
(log/debug "get-fcm-token:" x)
(re-frame/dispatch
[:notifications.callback/get-fcm-token-success x])))))
(defn create-notification-channel []
(let [channel (firebase.notifications.Android.Channel.
channel-id
channel-name
firebase.notifications.Android.Importance.High)]
(.setSound channel sound-name)
(.setShowBadge channel true)
(.enableVibration channel true)
(.. firebase
notifications
-android
(createChannel channel)
(then #(log/debug "Notification channel created:" channel-id)
#(log/error "Notification channel creation error:" channel-id %)))))
(defn- show-notification?
"Ignore push notifications from unknown contacts or removed chats"
[{:keys [db] :as cofx} {:keys [from] :as rehydrated-payload}]
(and (valid-notification-payload? rehydrated-payload)
(accounts.db/logged-in? cofx)
(some #(= (:public-key %) from)
(vals (:contacts/contacts db)))
(some #(= (:chat-id %) from)
(vals (:chats db)))))
(fx/defn handle-on-message
[{:keys [db] :as cofx} decoded-payload {:keys [force]}]
(let [view-id (:view-id db)
current-chat-id (:current-chat-id db)
app-state (:app-state db)
rehydrated-payload (rehydrate-payload cofx decoded-payload)
from (:from rehydrated-payload)]
(log/debug "handle-on-message" "app-state:" app-state
"view-id:" view-id "current-chat-id:" current-chat-id
"from:" from "force:" force)
(when (or force
(and
(not= app-state "active")
(show-notification? cofx rehydrated-payload)))
{:db
(assoc-in db [:push-notifications/stored (:to rehydrated-payload)]
(js/JSON.stringify (clj->js rehydrated-payload)))
:notifications/display-notification
{:title (i18n/label :notifications-new-message-title)
:body (i18n/label :notifications-new-message-body)
:decoded-payload rehydrated-payload}})))
(fx/defn handle-push-notification-open
[{:keys [db] :as cofx} decoded-payload {:keys [stored?] :as ctx}]
(let [current-public-key (accounts.db/current-public-key cofx)
nav-opts (when stored? {:navigation-reset? true})
rehydrated-payload (rehydrate-payload cofx decoded-payload)
from (:from rehydrated-payload)
to (:to rehydrated-payload)]
(log/debug "handle-push-notification-open"
"current-public-key:" current-public-key
"rehydrated-payload:" rehydrated-payload "stored?:" stored?)
(if (= to current-public-key)
(fx/merge cofx
{:db (update db :push-notifications/stored dissoc to)}
(chat-model/navigate-to-chat from nav-opts))
{:db (assoc-in db [:push-notifications/stored to]
(js/JSON.stringify (clj->js rehydrated-payload)))})))
;; https://github.com/invertase/react-native-firebase/blob/adcbeac3d11585dd63922ef178ff6fd886d5aa9b/src/modules/notifications/Notification.js#L13
(defn handle-notification-open-event [event]
(log/debug "handle-notification-open-event" event)
(let [decoded-payload (decode-notification-payload (.. event -notification))]
(when decoded-payload
(re-frame/dispatch
[:notifications/notification-open-event-received decoded-payload nil]))))
(defn handle-initial-push-notification []
"This method handles pending push notifications.
It is only needed to handle PNs from legacy clients
(which use firebase.notifications API)"
(log/debug "Handle initial push notifications")
(.. firebase
notifications
getInitialNotification
(then (fn [event]
(log/debug "getInitialNotification" event)
(when event
(handle-notification-open-event event))))))
(defn setup-token-refresh-callback []
(.onTokenRefresh
(.messaging firebase)
(fn [x]
(log/debug "onTokenRefresh:" x)
(re-frame/dispatch [:notifications.callback/get-fcm-token-success x]))))
(defn setup-on-notification-callback []
"Calling onNotification is only needed so that we're able to receive PNs"
"while in foreground from older clients who are still relying"
"on the notifications API. Once that is no longer a consideration"
"we can remove this method"
(log/debug "calling onNotification")
(.onNotification
(.notifications firebase)
(fn [message-js]
(log/debug "handle-on-notification-callback called")
(let [decoded-payload (decode-notification-payload message-js)]
(log/debug "handle-on-notification-callback payload:" decoded-payload)
(when decoded-payload
(re-frame/dispatch
[:notifications.callback/on-message decoded-payload]))))))
(defn setup-on-message-callback []
(log/debug "calling onMessage")
(.onMessage
(.messaging firebase)
(fn [message-js]
(log/debug "handle-on-message-callback called")
(let [decoded-payload (decode-notification-payload message-js)]
(log/debug "handle-on-message-callback decoded-payload:"
decoded-payload)
(when decoded-payload
(re-frame/dispatch
[:notifications.callback/on-message decoded-payload]))))))
(defn setup-on-notification-opened-callback []
(log/debug "setup-on-notification-opened-callback")
(.. firebase
notifications
(onNotificationOpened handle-notification-open-event)))
(defn init []
(log/debug "Init notifications")
(setup-token-refresh-callback)
(setup-on-message-callback)
(setup-on-notification-callback)
(setup-on-notification-opened-callback)
(when platform/android?
(create-notification-channel))
(handle-initial-push-notification)))
(fx/defn process-stored-event [cofx address stored-pns]
(when-not platform/desktop?
(if (accounts.db/logged-in? cofx)
(let [current-account (get-in cofx [:db :account/account])
current-address (:address current-account)
current-account-pubkey (:public-key current-account)
stored-pn-val-json (or (get stored-pns current-account-pubkey)
(get stored-pns (anonymize-pubkey current-account-pubkey)))
stored-pn-payload (if (= (first stored-pn-val-json) \{)
(js->clj (js/JSON.parse stored-pn-val-json) :keywordize-keys true)
{:from stored-pn-val-json
:to current-account-pubkey})
from (lookup-contact-pubkey-from-hash cofx (:from stored-pn-payload))
to (lookup-contact-pubkey-from-hash cofx (:to stored-pn-payload))]
(when (and from
(= address current-address))
(log/debug "process-stored-event" "address" address "from" from "to" to)
(handle-push-notification-open cofx
stored-pn-payload
{:stored? true})))
(log/error "process-stored-event called without user being logged in!"))))
(re-frame/reg-fx
:notifications/display-notification
display-notification)
(re-frame/reg-fx
:notifications/init
(fn []
(cond
platform/android?
(init)
platform/ios?
(utils/set-timeout init 100))))
(re-frame/reg-fx
:notifications/get-fcm-token
(fn [_]
(when platform/mobile?
(get-fcm-token))))
(re-frame/reg-fx
:notifications/request-notifications-permissions
(fn [_]
(request-permissions)))