--- /dev/null
+package fitbit
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "time"
+
+ "golang.org/x/oauth2"
+ oauth2fitbit "golang.org/x/oauth2/fitbit"
+)
+
+var oauth2Config = &oauth2.Config{
+ ClientID: "@FITBIT_CLIENT_ID@",
+ ClientSecret: "@FITBIT_CLIENT_SECRET@",
+ Endpoint: oauth2fitbit.Endpoint,
+ RedirectURL: "https://fitbit-gfit-sync.appspot.com/fitbit/grant",
+ Scopes: []string{"activity"},
+}
+
+type Activity struct {
+ ActivityID int `json:"activityId"`
+ ActivityParentID int `json:"activityParentId"`
+ Calories int `json:"calories"`
+ Description string `json:"description"`
+ Distance float64 `json:"distance"`
+ Duration int `json:"duration"`
+ HasStartTime bool `json:"hasStartTime"`
+ IsFavorite bool `json:"isFavorite"`
+ LogID int `json:"logId"`
+ Name string `json:"name"`
+ StartTime string `json:"startTime"`
+ Steps int `json:"steps"`
+}
+
+type Distance struct {
+ Activity string `json:"activity"`
+ Distance float64 `json:"distance"`
+}
+
+type ActivitySummary struct {
+ Activities []Activity `json:"activities"`
+ Goals struct {
+ CaloriesOut int `json:"caloriesOut"`
+ Distance float64 `json:"distance"`
+ Floors int `json:"floors"`
+ Steps int `json:"steps"`
+ } `json:"goals"`
+ Summary struct {
+ ActivityCalories int `json:"activityCalories"`
+ CaloriesBMR int `json:"caloriesBMR"`
+ CaloriesOut int `json:"caloriesOut"`
+ MarginalCalories int `json:"marginalCalories"`
+ Distances []Distance `json:"distances"`
+ Elevation float64 `json:"elevation"`
+ Floors int `json:"floors"`
+ Steps int `json:"steps"`
+ SedentaryMinutes int `json:"sedentaryMinutes"`
+ LightlyActiveMinutes int `json:"lightlyActiveMinutes"`
+ FairlyActiveMinutes int `json:"fairlyActiveMinutes"`
+ VeryActiveMinutes int `json:"veryActiveMinutes"`
+ } `json:"summary"`
+}
+
+type Client struct {
+ userID string
+ client *http.Client
+}
+
+func NewClient(ctx context.Context, userID string, tok *oauth2.Token) *Client {
+ return &Client{
+ userID: userID,
+ client: oauth2Config.Client(ctx, tok),
+ }
+}
+
+func (c *Client) ActivitySummary(t time.Time) (*ActivitySummary, error) {
+ url := fmt.Sprintf("https://api.fitbit.com/1/user/%s/activity/date/%s.json",
+ c.userID, t.Format("2006-01-02"))
+
+ res, err := c.client.Get(url)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+
+ var summary ActivitySummary
+ if err := json.NewDecoder(res.Body).Decode(&summary); err != nil {
+ return nil, err
+ }
+
+ return &summary, nil
+}
"net/url"
"time"
+ "github.com/google/uuid"
+ "github.com/octo/gfitsync/fitbit"
legacy_context "golang.org/x/net/context"
"golang.org/x/oauth2"
- "golang.org/x/oauth2/fitbit"
+ oauth2fitbit "golang.org/x/oauth2/fitbit"
"google.golang.org/appengine"
"google.golang.org/appengine/datastore"
"google.golang.org/appengine/delay"
var oauthConfig = &oauth2.Config{
ClientID: "@FITBIT_CLIENT_ID@",
ClientSecret: "@FITBIT_CLIENT_SECRET@",
- Endpoint: fitbit.Endpoint,
+ Endpoint: oauth2fitbit.Endpoint,
RedirectURL: "https://fitbit-gfit-sync.appspot.com/fitbit/grant", // TODO(octo): make full URL
Scopes: []string{"activity"},
}
-type storedToken struct {
- Email string
- oauth2.Token
-}
-
func init() {
http.HandleFunc("/setup", setupHandler)
http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
}
}
-type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *user.User) error
+type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *datastore.Key) error
+
+type User struct {
+ ID string
+}
func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ctx := appengine.NewContext(r)
}
err := datastore.RunInTransaction(ctx, func(ctx legacy_context.Context) error {
- key := RootKey(ctx, u)
- if err := datastore.Get(ctx, key, &struct{}{}); err != datastore.ErrNoSuchEntity {
+ key := datastore.NewKey(ctx, "User", u.Email, 0, nil)
+
+ if err := datastore.Get(ctx, key, &User{}); err != datastore.ErrNoSuchEntity {
return err // may be nil
}
- _, err := datastore.Put(ctx, key, &struct{}{})
+
+ _, err := datastore.Put(ctx, key, &User{
+ ID: uuid.New().String(),
+ })
return err
}, nil)
if err != nil {
return
}
- if err := hndl(ctx, w, r, u); err != nil {
+ rootKey := datastore.NewKey(ctx, "User", u.Email, 0, nil)
+ if err := hndl(ctx, w, r, rootKey); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
-func RootKey(ctx context.Context, u *user.User) *datastore.Key {
- return datastore.NewKey(ctx, "User", u.Email, 0, nil)
-}
-
-func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *user.User) error {
+func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, rootKey *datastore.Key) error {
var (
tok oauth2.Token
haveToken bool
)
- key := datastore.NewKey(ctx, "Token", "Fitbit", 0, RootKey(ctx, u))
+ key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
err := datastore.Get(ctx, key, &tok)
if err != nil && err != datastore.ErrNoSuchEntity {
return err
haveToken = true
}
+ u := user.Current(ctx)
+
// fmt.Fprintf(w, "u = %v, tok = %v, haveToken = %v\n", u, tok, haveToken)
fmt.Fprintln(w, "<html><body><h1>Fitbit to Google Fit sync</h1>")
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
-func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *user.User) error {
+func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, rootKey *datastore.Key) error {
if state := r.FormValue("state"); state != csrfToken {
return fmt.Errorf("invalid state parameter: %q", state)
}
return err
}
- key := datastore.NewKey(ctx, "Token", "Fitbit", 0, RootKey(ctx, u))
+ key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
if _, err := datastore.Put(ctx, key, tok); err != nil {
return err
}
-
c := oauthConfig.Client(ctx, tok)
+ var u User
+ if err := datastore.Get(ctx, rootKey, &u); err != nil {
+ return err
+ }
+
// create a subscription
- url := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/apiSubscriptions/%s.json",
- RootKey(ctx, u).Encode())
+ url := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/apiSubscriptions/%s.json", u.ID)
res, err := c.Post(url, "", nil)
if err != nil {
return err
}
defer res.Body.Close()
+ if res.StatusCode == http.StatusConflict {
+ var n fitbitSubscription
+ if err := json.NewDecoder(res.Body).Decode(&n); err != nil {
+ return err
+ }
+ log.Warningf(ctx, "conflict with existing subscription %v", n)
+ }
+
if res.StatusCode >= 400 {
- data, _ := ioutil.ReadAll(r.Body)
+ data, _ := ioutil.ReadAll(res.Body)
log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data)
return fmt.Errorf("creating subscription failed")
}
return nil
}
-type fitbitNotification struct {
+type fitbitSubscription struct {
CollectionType string `json:"collectionType"`
Date string `json:"date"`
OwnerID string `json:"ownerId"`
SubscriptionID string `json:"subscriptionId"`
}
-func (n *fitbitNotification) URLValues() url.Values {
+func (s *fitbitSubscription) URLValues() url.Values {
return url.Values{
- "CollectionType": []string{n.CollectionType},
- "Date": []string{n.Date},
- "OwnerID": []string{n.OwnerID},
- "OwnerType": []string{n.OwnerType},
- "SubscriptionID": []string{n.SubscriptionID},
+ "CollectionType": []string{s.CollectionType},
+ "Date": []string{s.Date},
+ "OwnerID": []string{s.OwnerID},
+ "OwnerType": []string{s.OwnerType},
+ "SubscriptionID": []string{s.SubscriptionID},
}
}
-func (n *fitbitNotification) URL() string {
+func (s *fitbitSubscription) URL() string {
+ // daily summary: GET https://api.fitbit.com/1/user/[user-id]/activities/date/[date].json
return fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/date/%s.json",
- n.OwnerID, n.CollectionType, n.Date)
+ s.OwnerID, s.CollectionType, s.Date)
}
func checkSignature(ctx context.Context, payload []byte, rawSig string) bool {
// handleNotifications parses fitbit notifications and requests the individual
// activities from Fitbit. It is executed asynchronously via the delay package.
func handleNotifications(ctx context.Context, payload []byte) error {
- var notifications []fitbitNotification
- if err := json.Unmarshal(payload, notifications); err != nil {
+ var subscriptions []fitbitSubscription
+ if err := json.Unmarshal(payload, &subscriptions); err != nil {
return err
}
- for _, n := range notifications {
- if n.CollectionType != "activities" {
+ for _, s := range subscriptions {
+ if s.CollectionType != "activities" {
continue
}
- if err := handleNotification(ctx, &n); err != nil {
+ if err := handleNotification(ctx, &s); err != nil {
log.Errorf(ctx, "handleNotification() = %v", err)
continue
}
return nil
}
-func handleNotification(ctx context.Context, n *fitbitNotification) error {
- rootKey, err := datastore.DecodeKey(n.SubscriptionID)
+func handleNotification(ctx context.Context, s *fitbitSubscription) error {
+ q := datastore.NewQuery("User").Filter("ID=", s.SubscriptionID).KeysOnly()
+ keys, err := q.GetAll(ctx, nil)
if err != nil {
- return err
+ return fmt.Errorf("datastore.Query.GetAll(): %v", err)
+ }
+ if len(keys) != 1 {
+ return fmt.Errorf("len(keys) = %d, want 1", len(keys))
}
+
+ rootKey := keys[0]
key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
var tok oauth2.Token
return err
}
- c := oauthConfig.Client(ctx, &tok)
- res, err := c.Get(n.URL())
+ c := fitbit.NewClient(ctx, s.OwnerID, &tok)
+
+ t, err := time.Parse("2006-01-02", s.Date)
+ if err != nil {
+ return err
+ }
+
+ summary, err := c.ActivitySummary(t)
if err != nil {
return err
}
- log.Infof(ctx, "GET %s = %v", n.URL(), res)
+ log.Debugf(ctx, "ActivitySummary() = %v", summary)
return nil
}