Présentation des notifications push multiplateformes: mise en route

Bonne journée! Je m'appelle Vladimir Stolyarov, je suis développeur back-end dans l'équipe de communication client chez DomKlik. Dans cet article, je vais vous expliquer comment implémenter les notifications push multiplateformes. Bien que beaucoup de choses aient déjà été écrites à ce sujet, je voudrais parler de certaines des nuances auxquelles nous avons dû faire face dans le processus de mise en œuvre. Pour une meilleure compréhension de ce qui se passe, nous écrirons également avec vous une petite application web pouvant accepter les notifications push.





Vous devez d'abord comprendre oĂą nous voulons envoyer des notifications push. Dans notre cas, il s'agit d'un site Web, d'une application iOS et d'une application Android.



Commençons par les notifications push Web. Pour les recevoir, le navigateur se connecte à son serveur push, s'identifie et reçoit des notifications au service worker (un événement y est déclenché push). La nuance ici est que chaque navigateur a son propre service push:





, - IETF (https://datatracker.ietf.org/wg/webpush/documents/), API , .



Android. :





iOS. Android, Apple — Apple Push Notification service (APNs).



: , API ? , , Firebase Cloud Messaging, Android, - APNs. : Huawei Google Apps Huawei Push Kit, Firebase Cloud Messaging.





, :



  1. - — .
  2. , .
  3. .




- Firebase . Firebase -. HTTP- .



:



simple_example.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>   </title>
    </head>
    <body>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-messaging.js"></script>
    <script>
        function toClipboard(text) {
            const tmp = document.createElement('textarea');
            tmp.hidden = true;
            tmp.value = text;
            window.document.body.appendChild(tmp);
            tmp.select();
            window.document.execCommand("copy");
            alert("Copied the text: " + text);
            window.document.body.removeChild(tmp);
        }
    </script>
    <button onclick="enableNotifications()"> </button>
    <div id="pushTokenLayer" hidden>
        Firebase token <code id="pushTokenValue" style="cursor:pointer" onclick="toClipboard(this.innerText)"></code><br/>
    </div>
    <script>
        async function enableNotifications() {
            // Insert your firebase project config here
            const firebaseConfig = {};

            const app = firebase.initializeApp(firebaseConfig);
            const messaging = app.messaging();

            const permission = await Notification.requestPermission();
            if (permission !== 'granted') {
                console.log("user denied notifications")
            }

            const token = await messaging.getToken();

            window.document.getElementById("pushTokenLayer").removeAttribute("hidden");

            const pushTokenValue = window.document.getElementById("pushTokenValue");
            pushTokenValue.innerText = token
        }
    </script>
    </body>
</html>


-. /firebase-messaging-sw.js. .



, , . API ( ). :



curl -X POST 'https://fcm.googleapis.com/fcm/send' \
-H 'Authorization: key=<fcm server key>' \
-H 'Content-Type: application/json' \
-d '{
 "to" : "<  >",
 "notification" : {
     "body" : "Body of Your Notification",
     "title": "Title of Your Notification"
 }
}'


:





, : - , (.. ). .



, . setBackgroundMessageHandler. -, :



messaging.setBackgroundMessageHandler((payload) => {
  console.log('Message received. ', payload);
  // ...
});


-, … , . ? :



Note: If you set notification fields in your message payload, your setBackgroundMessageHandler callback is not called, and instead the SDK displays a notification based on your payload.

notification . , .



, , . firebase-messaging-sw.js:



firebase-messaging-sw.js
self.addEventListener("push", event => { event.waitUntil(onPush(event)) });

async function onPush(event) {
    const push = event.data.json();
    console.log("push received", push)

    const { notification = {} } = {...push};

    await self.registration.showNotification(notification.title, {
        body: notification.body,
    })
}


json js-, , . waitUntil : , - onPush.





:



user_example.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8">
        <title>   </title>
    </head>
    <body>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-app.js"></script>
    <script src="https://www.gstatic.com/firebasejs/7.14.5/firebase-messaging.js"></script>
    <script>
        function toClipboard(text) {
            const tmp = document.createElement('textarea');
            tmp.hidden = true;
            tmp.value = text;
            window.document.body.appendChild(tmp);
            tmp.select();
            window.document.execCommand("copy");
            alert("Copied the text: " + text);
            window.document.body.removeChild(tmp);
        }
    </script>
    <form onsubmit="enableNotifications(this); return false" action="#">
        User ID <input type="number" name="userID" required/>
        <input type="submit" value=" "/>
    </form>
    <div id="pushTokenLayer" hidden>
        Firebase token <code id="pushTokenValue" style="cursor:pointer" onclick="toClipboard(this.innerText)"></code><br/>
        <button onclick="logout()"></button>
    </div>
    <script>
        // Insert your firebase project config here
        const firebaseConfig = {};

        const app = firebase.initializeApp(firebaseConfig);
        const messaging = app.messaging(); // this fails if browser not supported

        async function getMe() {
            const resp = await fetch(`${window.location.origin}/api/v1/users/me`, {
                credentials: "include",
            });

            if (resp.status === 401) {
                return null;
            }
            if (!resp.ok) {
                throw `unexpected status code ${resp.status}`
            }

            return await resp.json();
        }

        async function sendToken(token) {
            const me = await getMe();
            if (me === null) {
                console.error("unauthorized on send token");
                return;
            }

            window.localStorage.getItem("push-token-user");

            const resp = await fetch(`${window.location.origin}/api/v1/tokens`, {
                method: "POST",
                body: JSON.stringify({
                    token: {token: token, platform: "web"}
                }),
                credentials: "include",
            })
            if (!resp.ok) {
                console.error("send token failed");
                return;
            }

            // put current user to local storage for comparison
            window.localStorage.setItem("push-token-user", JSON.stringify(me));
        }

        getMe().
            then(me => {
                if (!me) {
                    // if user not authorized we must invalidate firebase registration
                    // to prevent receiving pushes for unauthorized user
                    // this may happen i.e. if 'deleteToken' failed on logout
                    console.log(`user unauthorized, invalidate fcm registration`);
                    window.localStorage.removeItem("push-token-user");
                    messaging.deleteToken();
                    return null;
                }

                // if user authorized and it's not user that received push token earlier
                // we also must invalidate token to prevent receiving pushes for wrong user
                // this may happen if i.e. user not logged out explicitly
                let pushTokenUser = window.localStorage.getItem("push-token-user");
                if (pushTokenUser && JSON.parse(pushTokenUser).id !== me.id) {
                    console.log("token for wrong user, invalidate fcm registration");
                    window.localStorage.removeItem("push-token-user");
                    messaging.deleteToken();
                    pushTokenUser = null;
                }

                // if user authorized and permission granted but token wasn't send we should re-send it
                if (!pushTokenUser && Notification.permission === "granted") {
                    console.log("token not sent to server while notification permission granted");
                    messaging.getToken().then(sendToken);
                }
            }).
            catch(e => console.log("get me error", e))

        // according to sources of firebase-js-sdk source code registration token refreshed once a week
        messaging.onTokenRefresh(async () => {
            const newToken = await messaging.getToken();
            pushTokenValue.innerText = newToken;
            console.log(`updated token to ${newToken}`)
            await sendToken(newToken)
        })

        async function enableNotifications(form) {
            const loginResponse = await fetch(`${window.location.origin}/api/v1/users/login`, {
                method: "POST",
                body: JSON.stringify({
                    id: Number(form.elements.userID.value),
                })
            })
            if (!loginResponse.ok) {
                alert("login failed");
                return;
            }

            const permission = await Notification.requestPermission();
            if (permission !== 'granted') {
                console.log("user denied notifications")
                return;
            }

            const token = await messaging.getToken();

            window.document.getElementById("pushTokenLayer").removeAttribute("hidden");

            const pushTokenValue = window.document.getElementById("pushTokenValue");
            pushTokenValue.innerText = token

            await sendToken(token)
        }

        async function logout() {
            const messaging = firebase.messaging();
            await messaging.deleteToken();
            console.log(`deleted token from firebase`)
            window.document.getElementById("pushTokenLayer").setAttribute("hidden", "");
            await fetch(`${window.location.origin}/api/v1/users/logout`, {
                method: "POST",
                credentials: "include",
            })
        }
    </script>
    </body>
</html>


, Go. , :



inmemory.go
type MemoryStorage struct {
    mu          sync.RWMutex
    userTokens  map[uint64][]Token
    tokenOwners map[string]uint64
}

func NewMemoryStorage() *MemoryStorage {
    return &MemoryStorage{
        userTokens:  map[uint64][]Token{},
        tokenOwners: map[string]uint64{},
    }
}

type Token struct {
    Token    string `json:"token"`
    Platform string `json:"platform"`
}

func (ms *MemoryStorage) SaveToken(ctx context.Context, userID uint64, token Token) error {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    owner, ok := ms.tokenOwners[token.Token]
    // if old user comes with some token it's ok
    if owner == userID {
        return nil
    }
    // if new user come with existing token we
    // should change it's owner to prevent push target mismatch
    if ok {
        ms.deleteTokenFromUser(token.Token, owner)
    }

    ut := ms.userTokens[userID]
    ut = append(ut, token)
    ms.userTokens[userID] = ut

    ms.tokenOwners[token.Token] = userID

    return nil
}

func (ms *MemoryStorage) deleteTokenFromUser(token string, userID uint64) {
    ut := ms.userTokens[userID]
    for i, t := range ut {
        if t.Token == token {
            ut[i], ut[len(ut)-1] = ut[len(ut)-1], Token{}
            ut = ut[:len(ut)-1]
            break
        }
    }
    ms.userTokens[userID] = ut
}

func (ms *MemoryStorage) UserTokens(ctx context.Context, userID uint64) ([]Token, error) {
    ms.mu.RLock()
    defer ms.mu.RUnlock()

    tokens := ms.userTokens[userID]
    ret := make([]Token, len(tokens))
    copy(ret, tokens)

    return ret, nil
}

func (ms *MemoryStorage) DeleteTokens(ctx context.Context, tokens []string) error {
    ms.mu.Lock()
    defer ms.mu.Unlock()

    for _, token := range tokens {
        user, ok := ms.tokenOwners[token]
        if !ok {
            return nil
        }

        ms.deleteTokenFromUser(token, user)
    }
    return nil
}


.



:



  • , - . - / .
  • -. Firebase , , .
  • - . .


, ( ):



  • - , , , . firebase-js-sdk, , onTokenRefresh .
  • -. , Firebase . .
  • . .. , . . - , . , - . : .
  • , . : , ( Android/iOS , — ), .


, , . … ?





, Huawei . . , — HTTP- . , Firebase, Huawei : .



: ( UUID) . HTTP-, . firebase-messaging-sw.js:



firebase-messaging-sw.js
self.addEventListener("push", event => { event.waitUntil(onPush(event)) });

async function onPush(event) {
    const push = event.data.json();
    console.log("push received", push)

    const { notification = {}, data = {} } = {...push};

    await self.registration.showNotification(notification.title, {
        body: notification.body,
    })

    if (data.id) {
        await fetch(`${self.location.origin}/api/v1/notifications/${data.id}/confirm`, { method: "POST" })
    }
}


, . setBackgroundMessageHandler? , , Firebase ( Huawei) ( API) , , ( notification) data-. , , data- , .



- , firebase-js-sdk -, Android . Android data notification, .



APNs mutable-content 1, , , HTTP-. , - iOS , .



: data- , - , . , , Telegram , .



: , , , 15 , . , , TTL .



. , :



  • Android ( Huawei) — 40 %
  • Web — 50 %
  • iOS — 70 %


Huawei . , , , , ..



:





GitHub.



, -, , , -, .




All Articles