-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
Messaging: deliver events to companion app via push notifications #31910
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,141 @@ | ||
| package messenger | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "slices" | ||
| "strings" | ||
| "sync" | ||
|
|
||
| "github.com/evcc-io/evcc/core/keys" | ||
| "github.com/evcc-io/evcc/server/db/settings" | ||
| "github.com/evcc-io/evcc/util" | ||
| "github.com/evcc-io/evcc/util/request" | ||
| ) | ||
|
|
||
| const ( | ||
| expoPushURI = "https://exp.host/--/api/v2/push/send" | ||
|
|
||
| // Expo push tokens have the form ExponentPushToken[xxxxxxxx] | ||
| tokenPrefix = "ExponentPushToken[" | ||
| tokenSuffix = "]" | ||
| maxTokenLen = 128 | ||
| maxTokens = 20 | ||
| ) | ||
|
|
||
| // AppPush sends messages to registered companion app devices via the Expo push | ||
| // service. The app registers its device token through the /api/push/token endpoint. | ||
| type AppPush struct { | ||
| mu sync.Mutex | ||
| log *util.Logger | ||
| tokens []string | ||
| } | ||
|
|
||
| // NewAppPushFromSettings creates an AppPush messenger with tokens restored from settings | ||
| func NewAppPushFromSettings() *AppPush { | ||
| m := &AppPush{log: util.NewLogger("apppush")} | ||
| _ = settings.Json(keys.PushTokens, &m.tokens) | ||
| return m | ||
| } | ||
|
|
||
| // ValidPushToken checks the Expo push token format | ||
| func ValidPushToken(token string) bool { | ||
| return len(token) <= maxTokenLen && | ||
| strings.HasPrefix(token, tokenPrefix) && | ||
| strings.HasSuffix(token, tokenSuffix) | ||
| } | ||
|
|
||
| // Register adds a device token | ||
| func (m *AppPush) Register(token string) { | ||
| if !ValidPushToken(token) { | ||
| return | ||
| } | ||
|
|
||
| m.mu.Lock() | ||
| defer m.mu.Unlock() | ||
|
|
||
| if slices.Contains(m.tokens, token) { | ||
| return | ||
| } | ||
|
|
||
| // drop oldest when full | ||
| if len(m.tokens) >= maxTokens { | ||
| m.tokens = m.tokens[len(m.tokens)-maxTokens+1:] | ||
| } | ||
|
|
||
| m.tokens = append(m.tokens, token) | ||
| m.persist() | ||
| } | ||
|
|
||
| // Unregister removes a device token | ||
| func (m *AppPush) Unregister(token string) { | ||
| m.mu.Lock() | ||
| defer m.mu.Unlock() | ||
|
|
||
| if i := slices.Index(m.tokens, token); i >= 0 { | ||
| m.tokens = slices.Delete(m.tokens, i, i+1) | ||
| m.persist() | ||
| } | ||
| } | ||
|
|
||
| // persist must be called with mu held | ||
| func (m *AppPush) persist() { | ||
| if err := settings.SetJson(keys.PushTokens, m.tokens); err != nil { | ||
| m.log.ERROR.Println(err) | ||
| } | ||
| } | ||
|
|
||
| type expoPushMessage struct { | ||
| To string `json:"to"` | ||
| Title string `json:"title,omitempty"` | ||
| Body string `json:"body"` | ||
| } | ||
|
|
||
| type expoPushResponse struct { | ||
| Data []struct { | ||
| Status string `json:"status"` | ||
| Message string `json:"message"` | ||
| Details struct { | ||
| Error string `json:"error"` | ||
| } `json:"details"` | ||
| } `json:"data"` | ||
| } | ||
|
|
||
| // Send implements the api.Messenger interface | ||
| func (m *AppPush) Send(title, msg string) { | ||
| m.mu.Lock() | ||
| tokens := slices.Clone(m.tokens) | ||
| m.mu.Unlock() | ||
|
|
||
| if len(tokens) == 0 { | ||
| return | ||
| } | ||
|
|
||
| messages := make([]expoPushMessage, 0, len(tokens)) | ||
| for _, to := range tokens { | ||
| messages = append(messages, expoPushMessage{To: to, Title: title, Body: msg}) | ||
| } | ||
|
|
||
| req, err := request.New(http.MethodPost, expoPushURI, request.MarshalJSON(messages), request.JSONEncoding) | ||
| if err != nil { | ||
| m.log.ERROR.Println(err) | ||
| return | ||
| } | ||
|
|
||
| var res expoPushResponse | ||
| if err := request.NewHelper(m.log).DoJSON(req, &res); err != nil { | ||
| m.log.ERROR.Println(err) | ||
| return | ||
| } | ||
|
|
||
| // responses are order-aligned with the request | ||
| for i, r := range res.Data { | ||
| if r.Status != "ok" && i < len(tokens) { | ||
| m.log.WARN.Printf("push failed: %s %s", r.Message, r.Details.Error) | ||
|
|
||
| // prune devices that are no longer registered | ||
| if r.Details.Error == "DeviceNotRegistered" { | ||
| m.Unregister(tokens[i]) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package messenger | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/evcc-io/evcc/util" | ||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| // token of exactly the given total length | ||
| func tokenOfLen(l int) string { | ||
| return tokenPrefix + strings.Repeat("x", l-len(tokenPrefix)-len(tokenSuffix)) + tokenSuffix | ||
| } | ||
|
|
||
| func TestValidPushToken(t *testing.T) { | ||
| tc := []struct { | ||
| name string | ||
| token string | ||
| valid bool | ||
| }{ | ||
| {"typical", "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]", true}, | ||
| {"empty", "", false}, | ||
| {"garbage", "foo", false}, | ||
| {"unterminated", "ExponentPushToken[unterminated", false}, | ||
| {"max length", tokenOfLen(maxTokenLen), true}, | ||
| {"too long", tokenOfLen(maxTokenLen + 1), false}, | ||
| } | ||
|
|
||
| for _, tc := range tc { | ||
| t.Run(tc.name, func(t *testing.T) { | ||
| assert.Equal(t, tc.valid, ValidPushToken(tc.token)) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestAppPushRegister(t *testing.T) { | ||
| m := &AppPush{log: util.NewLogger("test")} | ||
|
|
||
| m.Register("ExponentPushToken[a]") | ||
| m.Register("ExponentPushToken[a]") // duplicate | ||
| m.Register("ExponentPushToken[b]") | ||
| m.Register("invalid") | ||
| assert.Equal(t, []string{"ExponentPushToken[a]", "ExponentPushToken[b]"}, m.tokens) | ||
|
|
||
| m.Unregister("ExponentPushToken[a]") | ||
|
Comment on lines
+37
to
+46
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (testing): Add tests for max token capacity and pruning behavior in AppPush.Register The test currently covers register/unregister and duplicates but not the
Suggested implementation: func TestAppPushRegister(t *testing.T) {
m := &AppPush{log: util.NewLogger("test")}
m.Register("ExponentPushToken[a]")
m.Register("ExponentPushToken[a]") // duplicate
m.Register("ExponentPushToken[b]")
m.Register("invalid")
assert.Equal(t, []string{"ExponentPushToken[a]", "ExponentPushToken[b]"}, m.tokens)
m.Unregister("ExponentPushToken[a]")
assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens)
m.Unregister("ExponentPushToken[unknown]")
assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens)
}
func TestAppPushRegisterMaxTokensPrunesOldest(t *testing.T) {
m := &AppPush{log: util.NewLogger("test")}
// Fill up to maxTokens with valid, distinct tokens
for i := 0; i < maxTokens; i++ {
token := fmt.Sprintf("ExponentPushToken[%d]", i)
m.Register(token)
}
// Sanity check: we should be at capacity
assert.Len(t, m.tokens, maxTokens)
assert.Equal(t, "ExponentPushToken[0]", m.tokens[0])
// Register one more valid token; this should prune the oldest
newToken := "ExponentPushToken[new]"
m.Register(newToken)
// Capacity must remain maxTokens
assert.Len(t, m.tokens, maxTokens)
// Oldest token should have been removed
assert.NotContains(t, m.tokens, "ExponentPushToken[0]")
// New token should be present
assert.Contains(t, m.tokens, newToken)
}If <<<<<<< SEARCH )import ( )
Also ensure that |
||
| assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens) | ||
|
|
||
| m.Unregister("ExponentPushToken[unknown]") | ||
| assert.Equal(t, []string{"ExponentPushToken[b]"}, m.tokens) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| package server | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "errors" | ||
| "net/http" | ||
|
|
||
| "github.com/evcc-io/evcc/messenger" | ||
| "github.com/gorilla/handlers" | ||
| ) | ||
|
|
||
| // RegisterAppPushHandlers adds the companion app push token endpoints | ||
| func (s *HTTPd) RegisterAppPushHandlers(m *messenger.AppPush) { | ||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||
| api := s.Router().PathPrefix("/api").Subrouter() | ||
| api.Use(jsonHandler) | ||
| api.Use(handlers.CompressHandler) | ||
| api.Use(handlers.CORS( | ||
| handlers.AllowedHeaders([]string{"Content-Type"}), | ||
| )) | ||
|
|
||
| routes := map[string]route{ | ||
| "registerpushtoken": {"POST", "/push/token", pushTokenHandler(m.Register)}, | ||
| "unregisterpushtoken": {"DELETE", "/push/token", pushTokenHandler(m.Unregister)}, | ||
| } | ||
|
|
||
| for _, r := range routes { | ||
| api.Methods(r.Methods()...).Path(r.Pattern).Handler(r.HandlerFunc) | ||
| } | ||
| } | ||
|
|
||
| func pushTokenHandler(fun func(string)) http.HandlerFunc { | ||
| return func(w http.ResponseWriter, r *http.Request) { | ||
| var req struct { | ||
| Token string `json:"token"` | ||
| } | ||
|
|
||
| if err := json.NewDecoder(r.Body).Decode(&req); err != nil { | ||
| jsonError(w, http.StatusBadRequest, err) | ||
| return | ||
| } | ||
|
|
||
| if !messenger.ValidPushToken(req.Token) { | ||
| jsonError(w, http.StatusBadRequest, errors.New("invalid push token")) | ||
| return | ||
| } | ||
|
|
||
| fun(req.Token) | ||
| jsonWrite(w, true) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.