Skip to content

Commit

Permalink
Ignore ack and notification counts if notifications are blocked by th…
Browse files Browse the repository at this point in the history
…e device (mattermost#27570)

* Ignore performance counts if notifications are blocked by the device

* Change the endpoint to allow more information

* Add tests and API description

* Remove wrong test

* Address feedback

* Only update the cache when there is no error

* Follow same casing as other props

* use one single endpoint

* Fix tests

* Fix i18n

---------

Co-authored-by: Mattermost Build <[email protected]>
  • Loading branch information
larkox and mattermost-build authored Sep 11, 2024
1 parent b9debc7 commit af503d9
Show file tree
Hide file tree
Showing 13 changed files with 329 additions and 48 deletions.
22 changes: 16 additions & 6 deletions api/v4/source/users.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1752,27 +1752,37 @@
put:
tags:
- users
summary: Attach mobile device
summary: Attach mobile device and extra props to the session object
description: >
Attach a mobile device id to the currently logged in session. This will
enable push notifications for a user, if configured by the server.
Attach extra props to the session object of the currently logged in session.
Adding a mobile device id will enable push notifications for a user,
if configured by the server.
Other props are also available, like whether the device has notifications
disabled and the mobile version.
##### Permissions
Must be authenticated.
operationId: AttachDeviceId
operationId: AttachDeviceExtraProps
requestBody:
content:
application/json:
schema:
type: object
required:
- device_id
properties:
device_id:
description: Mobile device id. For Android prefix the id with `android:`
and Apple with `apple:`
type: string
deviceNotificationDisabled:
description: Whether the mobile device has notifications disabled.
Accepted values are "true" or "false".
type: string
mobileVersion:
description: Mobile app version. The version must be parseable as a semver.
type: string
required: true
responses:
"200":
Expand Down
14 changes: 13 additions & 1 deletion server/channels/api4/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,19 @@ func pushNotificationAck(c *Context, w http.ResponseWriter, r *http.Request) {
}

if ack.NotificationType == model.PushTypeMessage {
c.App.CountNotificationAck(model.NotificationTypePush, ack.ClientPlatform)
session := c.AppContext.Session()
ignoreNotificationACK := session.Props[model.SessionPropDeviceNotificationDisabled] == "true"
if ignoreNotificationACK && ack.ClientPlatform == "ios" {
// iOS doesn't send ack when the notificications are disabled
// so we restore the value the moment we receive an ack
c.App.SetExtraSessionProps(session, map[string]string{
model.SessionPropDeviceNotificationDisabled: "false",
})
c.App.ClearSessionCacheForUser(session.UserId)
}
if !ignoreNotificationACK {
c.App.CountNotificationAck(model.NotificationTypePush, ack.ClientPlatform)
}
}

err := c.App.SendAckToPushProxy(&ack)
Expand Down
58 changes: 58 additions & 0 deletions server/channels/api4/system_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,64 @@ func TestPushNotificationAck(t *testing.T) {
assert.Equal(t, http.StatusForbidden, resp.Code)
assert.NotNil(t, resp.Body)
})

ttcc := []struct {
name string
propValue string
platform string
expectedValue string
}{
{
name: "should set session prop device notification disabled to false if an ack is sent from iOS",
propValue: "true",
platform: "ios",
expectedValue: "false",
},
{
name: "no change if empty",
propValue: "",
platform: "ios",
expectedValue: "",
},
{
name: "no change if false",
propValue: "false",
platform: "ios",
expectedValue: "false",
},
{
name: "no change on Android",
propValue: "true",
platform: "android",
expectedValue: "true",
},
}
for _, tc := range ttcc {
t.Run(tc.name, func(t *testing.T) {
defer func() {
session.AddProp(model.SessionPropDeviceNotificationDisabled, "")
th.Server.Store().Session().UpdateProps(session)
th.App.ClearSessionCacheForUser(session.UserId)
}()

session.AddProp(model.SessionPropDeviceNotificationDisabled, tc.propValue)
err := th.Server.Store().Session().UpdateProps(session)
th.App.ClearSessionCacheForUser(session.UserId)
assert.NoError(t, err)

handler := api.APIHandler(pushNotificationAck)
resp := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/v4/notifications/ack", nil)
req.Header.Set(model.HeaderAuth, "Bearer "+session.Token)
req.Body = io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"id":"123", "is_id_loaded":true, "platform": "%s", "post_id":"%s", "type": "%s"}`, tc.platform, th.BasicPost.Id, model.PushTypeMessage)))

handler.ServeHTTP(resp, req)
updatedSession, _ := th.App.GetSession(th.Client.AuthToken)
assert.Equal(t, tc.expectedValue, updatedSession.Props[model.SessionPropDeviceNotificationDisabled])
storeSession, _ := th.Server.Store().Session().Get(th.Context, session.Id)
assert.Equal(t, tc.expectedValue, storeSession.Props[model.SessionPropDeviceNotificationDisabled])
})
}
}

func TestCompleteOnboarding(t *testing.T) {
Expand Down
49 changes: 41 additions & 8 deletions server/channels/api4/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"strings"
"time"

"github.com/blang/semver/v4"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"

Expand Down Expand Up @@ -74,7 +75,7 @@ func (api *API) InitUser() {
api.BaseRoutes.User.Handle("/sessions/revoke", api.APISessionRequired(revokeSession)).Methods(http.MethodPost)
api.BaseRoutes.User.Handle("/sessions/revoke/all", api.APISessionRequired(revokeAllSessionsForUser)).Methods(http.MethodPost)
api.BaseRoutes.Users.Handle("/sessions/revoke/all", api.APISessionRequired(revokeAllSessionsAllUsers)).Methods(http.MethodPost)
api.BaseRoutes.Users.Handle("/sessions/device", api.APISessionRequired(attachDeviceId)).Methods(http.MethodPut)
api.BaseRoutes.Users.Handle("/sessions/device", api.APISessionRequired(handleDeviceProps)).Methods(http.MethodPut)
api.BaseRoutes.User.Handle("/audits", api.APISessionRequired(getUserAudits)).Methods(http.MethodGet)

api.BaseRoutes.User.Handle("/tokens", api.APISessionRequired(createUserAccessToken)).Methods(http.MethodPost)
Expand Down Expand Up @@ -2210,15 +2211,49 @@ func revokeAllSessionsAllUsers(c *Context, w http.ResponseWriter, r *http.Reques
ReturnStatusOK(w)
}

func attachDeviceId(c *Context, w http.ResponseWriter, r *http.Request) {
props := model.MapFromJSON(r.Body)
func handleDeviceProps(c *Context, w http.ResponseWriter, r *http.Request) {
receivedProps := model.MapFromJSON(r.Body)
deviceId := receivedProps["device_id"]

deviceId := props["device_id"]
if deviceId == "" {
c.SetInvalidParam("device_id")
newProps := map[string]string{}

deviceNotificationsDisabled := receivedProps[model.SessionPropDeviceNotificationDisabled]
if deviceNotificationsDisabled != "" {
if deviceNotificationsDisabled != "false" && deviceNotificationsDisabled != "true" {
c.SetInvalidParam(model.SessionPropDeviceNotificationDisabled)
return
}

newProps[model.SessionPropDeviceNotificationDisabled] = deviceNotificationsDisabled
}

mobileVersion := receivedProps[model.SessionPropMobileVersion]
if mobileVersion != "" {
if _, err := semver.Parse(mobileVersion); err != nil {
c.SetInvalidParam(model.SessionPropMobileVersion)
return
}
newProps[model.SessionPropMobileVersion] = mobileVersion
}

if deviceId != "" {
attachDeviceId(c, w, r, deviceId)
}

if c.Err != nil {
return
}

if err := c.App.SetExtraSessionProps(c.AppContext.Session(), newProps); err != nil {
c.Err = err
return
}

c.App.ClearSessionCacheForUser(c.AppContext.Session().UserId)
ReturnStatusOK(w)
}

func attachDeviceId(c *Context, w http.ResponseWriter, r *http.Request, deviceId string) {
auditRec := c.MakeAuditRecord("attachDeviceId", audit.Fail)
defer c.LogAuditRec(auditRec)
audit.AddEventParameter(auditRec, "device_id", deviceId)
Expand Down Expand Up @@ -2266,8 +2301,6 @@ func attachDeviceId(c *Context, w http.ResponseWriter, r *http.Request) {

auditRec.Success()
c.LogAudit("")

ReturnStatusOK(w)
}

func getUserAudits(c *Context, w http.ResponseWriter, r *http.Request) {
Expand Down
85 changes: 77 additions & 8 deletions server/channels/api4/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3691,7 +3691,7 @@ func TestAttachDeviceId(t *testing.T) {
*cfg.ServiceSettings.SiteURL = tc.SiteURL
})

resp, err := th.Client.AttachDeviceId(context.Background(), deviceId)
resp, err := th.Client.AttachDeviceProps(context.Background(), map[string]string{"device_id": deviceId})
require.NoError(t, err)

cookies := resp.Header.Get("Set-Cookie")
Expand All @@ -3704,19 +3704,88 @@ func TestAttachDeviceId(t *testing.T) {
}
})

t.Run("invalid device id", func(t *testing.T) {
resp, err := th.Client.AttachDeviceId(context.Background(), "")
require.Error(t, err)
CheckBadRequestStatus(t, resp)
})

t.Run("not logged in", func(t *testing.T) {
th.Client.Logout(context.Background())

resp, err := th.Client.AttachDeviceId(context.Background(), "")
resp, err := th.Client.AttachDeviceProps(context.Background(), map[string]string{})
require.Error(t, err)
CheckUnauthorizedStatus(t, resp)
})

// Props related tests

client := th.CreateClient()
th.LoginBasicWithClient(client)

resetSession := func(session *model.Session) {
session.AddProp(model.SessionPropDeviceNotificationDisabled, "")
session.AddProp(model.SessionPropMobileVersion, "")
th.Server.Store().Session().UpdateProps(session)
th.App.ClearSessionCacheForUser(session.UserId)
}

t.Run("No props will return ok and no changes in the session", func(t *testing.T) {
session, _ := th.App.GetSession(client.AuthToken)
defer resetSession(session)
res, err := client.AttachDeviceProps(context.Background(), map[string]string{})
assert.NoError(t, err)

updatedSession, _ := th.App.GetSession(client.AuthToken)
storeSession, _ := th.Server.Store().Session().Get(th.Context, session.Id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, session.Props, updatedSession.Props)
assert.Equal(t, session.Props, storeSession.Props)
})
t.Run("Unknown props will be ignored, returning ok and no changes in the session", func(t *testing.T) {
session, _ := th.App.GetSession(client.AuthToken)
defer resetSession(session)
res, err := client.AttachDeviceProps(context.Background(), map[string]string{"unknownProp": "foo"})
assert.NoError(t, err)

updatedSession, _ := th.App.GetSession(client.AuthToken)
storeSession, _ := th.Server.Store().Session().Get(th.Context, session.Id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, session.Props, updatedSession.Props)
assert.Equal(t, session.Props, storeSession.Props)
})
t.Run("Invalid disabled notification prop will return an error and no changes in the session", func(t *testing.T) {
session, _ := th.App.GetSession(client.AuthToken)
defer resetSession(session)
res, err := client.AttachDeviceProps(context.Background(), map[string]string{model.SessionPropDeviceNotificationDisabled: "foo"})
assert.Error(t, err)

updatedSession, _ := th.App.GetSession(client.AuthToken)
storeSession, _ := th.Server.Store().Session().Get(th.Context, session.Id)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assert.Equal(t, session.Props, updatedSession.Props)
assert.Equal(t, session.Props, storeSession.Props)
})
t.Run("Invalid version will return an error and no changes in the session", func(t *testing.T) {
session, _ := th.App.GetSession(client.AuthToken)
defer resetSession(session)
res, err := client.AttachDeviceProps(context.Background(), map[string]string{model.SessionPropMobileVersion: "foo"})
assert.Error(t, err)

updatedSession, _ := th.App.GetSession(client.AuthToken)
storeSession, _ := th.Server.Store().Session().Get(th.Context, session.Id)
assert.Equal(t, http.StatusBadRequest, res.StatusCode)
assert.Equal(t, session.Props, updatedSession.Props)
assert.Equal(t, session.Props, storeSession.Props)
})
t.Run("Will update props", func(t *testing.T) {
session, _ := th.App.GetSession(client.AuthToken)
defer resetSession(session)
res, err := client.AttachDeviceProps(context.Background(), map[string]string{model.SessionPropDeviceNotificationDisabled: "true", model.SessionPropMobileVersion: "2.19.0"})
assert.NoError(t, err)

updatedSession, _ := th.App.GetSession(client.AuthToken)
storeSession, _ := th.Server.Store().Session().Get(th.Context, session.Id)
assert.Equal(t, http.StatusOK, res.StatusCode)
assert.Equal(t, "true", updatedSession.Props[model.SessionPropDeviceNotificationDisabled])
assert.Equal(t, "true", storeSession.Props[model.SessionPropDeviceNotificationDisabled])
assert.Equal(t, "2.19.0", updatedSession.Props[model.SessionPropMobileVersion])
assert.Equal(t, "2.19.0", storeSession.Props[model.SessionPropMobileVersion])
})
}

func TestGetUserAudits(t *testing.T) {
Expand Down
1 change: 1 addition & 0 deletions server/channels/app/app_iface.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion server/channels/app/notification_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,10 @@ func (a *App) sendPushNotificationToAllSessions(rctx request.CTX, msg *model.Pus
}

if msg.Type == model.PushTypeMessage {
a.CountNotification(model.NotificationTypePush, tmpMessage.Platform)
// If we are ignoring the ack, we don't count the send
if session.Props[model.SessionPropDeviceNotificationDisabled] != "true" {
a.CountNotification(model.NotificationTypePush, tmpMessage.Platform)
}
}
}

Expand Down
22 changes: 22 additions & 0 deletions server/channels/app/opentracing/opentracing_layer.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

23 changes: 23 additions & 0 deletions server/channels/app/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,29 @@ func (a *App) AttachDeviceId(sessionID string, deviceID string, expiresAt int64)
return nil
}

func (a *App) SetExtraSessionProps(session *model.Session, newProps map[string]string) *model.AppError {
changed := false
for k, v := range newProps {
if session.Props[k] == v {
continue
}

session.AddProp(k, v)
changed = true
}

if !changed {
return nil
}

err := a.Srv().Store().Session().UpdateProps(session)
if err != nil {
return model.NewAppError("SetExtraSessionProps", "app.session.set_extra_session_prop.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}

return nil
}

// ExtendSessionExpiryIfNeeded extends Session.ExpiresAt based on session lengths in config.
// A new ExpiresAt is only written if enough time has elapsed since last update.
// Returns true only if the session was extended.
Expand Down
Loading

0 comments on commit af503d9

Please sign in to comment.