Call ActivitySummary() and Profile() concurrently.
[kraftakt.git] / gfitsync.go
index 614d513..3bd416f 100644 (file)
@@ -2,19 +2,16 @@ package gfitsync
 
 import (
        "context"
-       "crypto/hmac"
-       "crypto/sha1"
-       "encoding/base64"
        "encoding/json"
        "fmt"
        "io/ioutil"
        "net/http"
-       "net/url"
+       "sync"
        "time"
 
-       legacy_context "golang.org/x/net/context"
-       "golang.org/x/oauth2"
-       "golang.org/x/oauth2/fitbit"
+       "github.com/octo/gfitsync/app"
+       "github.com/octo/gfitsync/fitbit"
+       "github.com/octo/gfitsync/gfit"
        "google.golang.org/appengine"
        "google.golang.org/appengine/datastore"
        "google.golang.org/appengine/delay"
@@ -22,30 +19,14 @@ import (
        "google.golang.org/appengine/user"
 )
 
-const csrfToken = "@CSRFTOKEN@"
-
-// var delayedHandleNotifications = delay.Func("handleNotifications", func(ctx legacy_context.Context, payload []byte) error {
-//     return handleNotifications(ctx, payload)
-// })
 var delayedHandleNotifications = delay.Func("handleNotifications", handleNotifications)
 
-var oauthConfig = &oauth2.Config{
-       ClientID:     "@FITBIT_CLIENT_ID@",
-       ClientSecret: "@FITBIT_CLIENT_SECRET@",
-       Endpoint:     fitbit.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.HandleFunc("/fitbit/setup", fitbitSetupHandler)
        http.Handle("/fitbit/grant", AuthenticatedHandler(fitbitGrantHandler))
        http.Handle("/fitbit/notify", ContextHandler(fitbitNotifyHandler))
+       http.HandleFunc("/google/setup", googleSetupHandler)
+       http.Handle("/google/grant", AuthenticatedHandler(googleGrantHandler))
        http.Handle("/", AuthenticatedHandler(indexHandler))
 }
 
@@ -61,13 +42,13 @@ func (hndl ContextHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        }
 }
 
-type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *user.User) error
+type AuthenticatedHandler func(context.Context, http.ResponseWriter, *http.Request, *app.User) error
 
 func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
        ctx := appengine.NewContext(r)
 
-       u := user.Current(ctx)
-       if u == nil {
+       gaeUser := user.Current(ctx)
+       if gaeUser == nil {
                url, err := user.LoginURL(ctx, r.URL.String())
                if err != nil {
                        http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -77,14 +58,7 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
                return
        }
 
-       err := datastore.RunInTransaction(ctx, func(ctx legacy_context.Context) error {
-               key := RootKey(ctx, u)
-               if err := datastore.Get(ctx, key, &struct{}{}); err != datastore.ErrNoSuchEntity {
-                       return err // may be nil
-               }
-               _, err := datastore.Put(ctx, key, &struct{}{})
-               return err
-       }, nil)
+       u, err := app.NewUser(ctx, gaeUser.Email)
        if err != nil {
                http.Error(w, err.Error(), http.StatusInternalServerError)
                return
@@ -96,66 +70,64 @@ func (hndl AuthenticatedHandler) ServeHTTP(w http.ResponseWriter, r *http.Reques
        }
 }
 
-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 {
-       var (
-               tok       oauth2.Token
-               haveToken bool
-       )
-
-       key := datastore.NewKey(ctx, "Token", "Fitbit", 0, RootKey(ctx, u))
-       err := datastore.Get(ctx, key, &tok)
+func indexHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
+       _, err := u.Token(ctx, "Fitbit")
        if err != nil && err != datastore.ErrNoSuchEntity {
                return err
        }
-       if err == nil {
-               haveToken = true
+       haveFitbitToken := err == nil
+
+       _, err = u.Token(ctx, "Google")
+       if err != nil && err != datastore.ErrNoSuchEntity {
+               return err
        }
+       haveGoogleToken := err == nil
 
-       fmt.Fprintf(w, "u = %v, tok = %v, haveToken = %v\n", u, tok, haveToken)
+       fmt.Fprintln(w, "<html><head><title>Kraftakt</title></head>")
+       fmt.Fprintln(w, "<body><h1>Kraftakt</h1>")
 
-       // TODO(octo): print summary to user
-       return nil
-}
+       fmt.Fprintln(w, "<p><strong>Kraftakt</strong> copies your <em>Fitbit</em> data to <em>Google Fit</em>, seconds after you sync.</p>")
 
-func setupHandler(w http.ResponseWriter, r *http.Request) {
-       url := oauthConfig.AuthCodeURL(csrfToken, oauth2.AccessTypeOffline)
-       http.Redirect(w, r, url, http.StatusTemporaryRedirect)
-}
+       fmt.Fprintf(w, "<p>Hello %s</p>\n", user.Current(ctx).Email)
+       fmt.Fprintln(w, "<ul>")
 
-func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *user.User) error {
-       if state := r.FormValue("state"); state != csrfToken {
-               return fmt.Errorf("invalid state parameter: %q", state)
+       fmt.Fprint(w, "<li>Fitbit: ")
+       if haveFitbitToken {
+               fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
+       } else {
+               fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/fitbit/setup">Authorize</a>)`)
        }
+       fmt.Fprintln(w, "</li>")
 
-       tok, err := oauthConfig.Exchange(ctx, r.FormValue("code"))
-       if err != nil {
-               return err
+       fmt.Fprint(w, "<li>Google Fit: ")
+       if haveGoogleToken {
+               fmt.Fprint(w, `<strong style="color: DarkGreen;">Authorized</strong>`)
+       } else {
+               fmt.Fprint(w, `<strong style="color: DarkRed;">Not authorized</strong> (<a href="/google/setup">Authorize</a>)`)
        }
+       fmt.Fprintln(w, "</li>")
 
-       key := datastore.NewKey(ctx, "Token", "Fitbit", 0, RootKey(ctx, u))
-       if _, err := datastore.Put(ctx, key, tok); err != nil {
-               return err
-       }
+       fmt.Fprintln(w, "</ul>")
+       fmt.Fprintln(w, "</body></html>")
 
-       c := oauthConfig.Client(ctx, tok)
+       return nil
+}
+
+func fitbitSetupHandler(w http.ResponseWriter, r *http.Request) {
+       http.Redirect(w, r, fitbit.AuthURL(), http.StatusTemporaryRedirect)
+}
 
-       // create a subscription
-       url := fmt.Sprintf("https://api.fitbit.com/1/user/-/activities/apiSubscriptions/%s.json",
-               RootKey(ctx, u).Encode())
-       res, err := c.Post(url, "", nil)
+func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
+       if err := fitbit.ParseToken(ctx, r, u); err != nil {
+               return err
+       }
+       c, err := fitbit.NewClient(ctx, "-", u)
        if err != nil {
                return err
        }
-       defer res.Body.Close()
 
-       if res.StatusCode >= 400 {
-               data, _ := ioutil.ReadAll(r.Body)
-               log.Errorf(ctx, "creating subscription failed: status %d %q", res.StatusCode, data)
-               return fmt.Errorf("creating subscription failed")
+       if err := c.Subscribe(ctx, "activities"); err != nil {
+               return fmt.Errorf("c.Subscribe() = %v", err)
        }
 
        redirectURL := r.URL
@@ -166,46 +138,21 @@ func fitbitGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Requ
        return nil
 }
 
-type fitbitNotification struct {
-       CollectionType string `json:"collectionType"`
-       Date           string `json:"date"`
-       OwnerID        string `json:"ownerId"`
-       OwnerType      string `json:"ownerType"`
-       SubscriptionID string `json:"subscriptionId"`
+func googleSetupHandler(w http.ResponseWriter, r *http.Request) {
+       http.Redirect(w, r, gfit.AuthURL(), http.StatusTemporaryRedirect)
 }
 
-func (n *fitbitNotification) 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},
-       }
-}
-
-func (n *fitbitNotification) URL() string {
-       return fmt.Sprintf("https://api.fitbit.com/1/user/%s/%s/date/%s.json",
-               n.OwnerID, n.CollectionType, n.Date)
-}
-
-func checkSignature(ctx context.Context, payload []byte, rawSig string) bool {
-       base64Sig, err := url.QueryUnescape(rawSig)
-       if err != nil {
-               log.Errorf(ctx, "QueryUnescape(%q) = %v", rawSig, err)
-               return false
-       }
-       signatureGot, err := base64.StdEncoding.DecodeString(base64Sig)
-       if err != nil {
-               log.Errorf(ctx, "base64.StdEncoding.DecodeString(%q) = %v", base64Sig, err)
-               return false
+func googleGrantHandler(ctx context.Context, w http.ResponseWriter, r *http.Request, u *app.User) error {
+       if err := gfit.ParseToken(ctx, r, u); err != nil {
+               return err
        }
 
-       mac := hmac.New(sha1.New, []byte(oauthConfig.ClientSecret+"&"))
-       mac.Write(payload)
-       signatureWant := mac.Sum(nil)
-
-       return hmac.Equal(signatureGot, signatureWant)
+       redirectURL := r.URL
+       redirectURL.Path = "/"
+       redirectURL.RawQuery = ""
+       redirectURL.Fragment = ""
+       http.Redirect(w, r, redirectURL.String(), http.StatusTemporaryRedirect)
+       return nil
 }
 
 // fitbitNotifyHandler is called by Fitbit whenever there are updates to a
@@ -236,7 +183,7 @@ func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Req
 
        // Fitbit recommendation: "If signature verification fails, you should
        // respond with a 404"
-       if !checkSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
+       if !fitbit.CheckSignature(ctx, data, r.Header.Get("X-Fitbit-Signature")) {
                w.WriteHeader(http.StatusNotFound)
                return nil
        }
@@ -252,17 +199,17 @@ func fitbitNotifyHandler(ctx context.Context, w http.ResponseWriter, r *http.Req
 // 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 []fitbit.Subscription
+       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
                }
@@ -271,24 +218,106 @@ func handleNotifications(ctx context.Context, payload []byte) error {
        return nil
 }
 
-func handleNotification(ctx context.Context, n *fitbitNotification) error {
-       rootKey, err := datastore.DecodeKey(n.SubscriptionID)
+func handleNotification(ctx context.Context, s *fitbit.Subscription) error {
+       u, err := app.UserByID(ctx, s.SubscriptionID)
        if err != nil {
                return err
        }
-       key := datastore.NewKey(ctx, "Token", "Fitbit", 0, rootKey)
 
-       var tok oauth2.Token
-       if err := datastore.Get(ctx, key, &tok); err != nil {
+       fitbitClient, err := fitbit.NewClient(ctx, s.OwnerID, u)
+       if err != nil {
                return err
        }
 
-       c := oauthConfig.Client(ctx, &tok)
-       res, err := c.Get(n.URL())
+       var (
+               wg      = &sync.WaitGroup{}
+               errs    appengine.MultiError
+               summary *fitbit.ActivitySummary
+               profile *fitbit.Profile
+       )
+
+       wg.Add(1)
+       go func() {
+               var err error
+               summary, err = fitbitClient.ActivitySummary(ctx, s.Date)
+               if err != nil {
+                       errs = append(errs, fmt.Errorf("fitbitClient.ActivitySummary(%q) = %v", s.Date, err))
+               }
+               wg.Done()
+       }()
+
+       wg.Add(1)
+       go func() {
+               var err error
+               profile, err = fitbitClient.Profile(ctx)
+               if err != nil {
+                       errs = append(errs, fmt.Errorf("fitbitClient.Profile(%q) = %v", s.Date, err))
+               }
+               wg.Done()
+       }()
+
+       wg.Wait()
+       if len(errs) != 0 {
+               return errs
+       }
+
+       tm, err := time.ParseInLocation("2006-01-02", s.Date, profile.Timezone)
        if err != nil {
                return err
        }
 
-       log.Infof(ctx, "GET %s = %v", n.URL(), res)
+       log.Debugf(ctx, "%s (%s) took %d steps on %s",
+               profile.Name, u.Email, summary.Summary.Steps, tm)
+
+       gfitClient, err := gfit.NewClient(ctx, u)
+       if err != nil {
+               return err
+       }
+
+       wg.Add(1)
+       go func() {
+               if err := gfitClient.SetSteps(ctx, summary.Summary.Steps, tm); err != nil {
+                       errs = append(errs, fmt.Errorf("gfitClient.SetSteps(%d) = %v", summary.Summary.Steps, err))
+               }
+               wg.Done()
+       }()
+
+       wg.Add(1)
+       go func() {
+               if err := gfitClient.SetCalories(ctx, summary.Summary.CaloriesOut, tm); err != nil {
+                       errs = append(errs, fmt.Errorf("gfitClient.SetCalories(%d) = %v", summary.Summary.CaloriesOut, err))
+               }
+               wg.Done()
+       }()
+
+       wg.Add(1)
+       go func() {
+               var distanceMeters float64
+               for _, d := range summary.Summary.Distances {
+                       if d.Activity != "total" {
+                               continue
+                       }
+                       distanceMeters = 1000.0 * d.Distance
+                       break
+               }
+               if err := gfitClient.SetDistance(ctx, distanceMeters, tm); err != nil {
+                       errs = append(errs, fmt.Errorf("gfitClient.SetDistance(%d) = %v", distanceMeters, err))
+               }
+               wg.Done()
+       }()
+
+       wg.Add(1)
+       go func() {
+               if err := gfitClient.SetHeartRate(ctx, summary.Summary.HeartRateZones, summary.Summary.RestingHeartRate, tm); err != nil {
+                       errs = append(errs, fmt.Errorf("gfitClient.SetHeartRate() = %v", err))
+               }
+               wg.Done()
+       }()
+
+       wg.Wait()
+
+       if len(errs) != 0 {
+               return errs
+       }
        return nil
 }