Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ Quick and convenient way to log your weight and daily water consumption.

## Notes

- Requires an application to be registered with Fitbit, set the `CLIENT_ID` and `CLIENT_SECRET` in `settings/oauth.secret.ts`.
- Requires an application to be registered with Fitbit, set the `FITBIT_CLIENT_ID` and `FITBIT_CLIENT_SECRET` in `common/oauth.secret.ts`.
- You must have atleast one recent weight log that has been entered via the Fitbit Companion app for the weight logger to initialise the values from.
3 changes: 3 additions & 0 deletions common/oauth.secret.example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const FITBIT_CLIENT_ID = '';
export const FITBIT_CLIENT_SECRET = '';
export const FITBIT_SCOPES = 'weight nutrition';
19 changes: 17 additions & 2 deletions companion/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { WaterResult, WeightResult } from './result';
import { UserResult, WaterResult, WeightResult } from './result';

export default class FitbitApi {
static baseUrl: string = 'https://api.fitbit.com/1/user/-';

private accessToken: string;

constructor(accessToken: string) {
constructor(accessToken?: string) {
this.setAccessToken(accessToken);
}

Expand All @@ -22,6 +22,21 @@ export default class FitbitApi {
this.accessToken = accessToken;
}

getUser = async (): Promise<UserResult> => {
try {
const response = await this.request('GET', [`${FitbitApi.baseUrl}/profile.json`]);

const data = await response.json();

return {
success: true,
display_name: data.user.displayName,
};
} catch (e) {
return { success: false };
}
};

getWeight = async (): Promise<WeightResult> => {
let now = new Date();
let todayDate = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`; //YYYY-MM-DD
Expand Down
5 changes: 5 additions & 0 deletions companion/api/result.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export interface UserResult {
success: boolean;
display_name?: string;
}

export interface WeightResult {
success: boolean;
weight?: number;
Expand Down
106 changes: 74 additions & 32 deletions companion/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import * as messaging from 'messaging';
import { settingsStorage } from 'settings';
import FitbitApi from './api/api';
import { getFitbitApiToken } from './oauth/fitbit_oauth';

const fbApi = new FitbitApi(getAccessToken());
const fbApi = new FitbitApi();

// Fetch weight data from Fitbit Web API
function fetchWeightData() {
Expand All @@ -29,48 +30,89 @@ function fetchWaterData() {
}

// User changes settings
settingsStorage.onchange = (evt) => {
// Handle oAuth response from settings
if (evt.key === 'oauth') {
let data = JSON.parse(evt.newValue);
fbApi.setAccessToken(data.access_token);
fetchWeightData();
fetchWaterData();
}
};

function restoreSettings() {
const accessToken = getAccessToken();
if (accessToken !== null) {
fetchWeightData();
fetchWaterData();
}
}
settingsStorage.onchange = async (evt) => {
// Received new OAuth code
if (evt.key === 'oauth_code' && evt.newValue) {
// Fetch refresh token
let token = await getFitbitApiToken(evt.newValue);
settingsStorage.setItem('refresh_token', token.refresh_token);

function getAccessToken() {
for (let index = 0; index < settingsStorage.length; index++) {
let key = settingsStorage.key(index);
if (key && key === 'oauth') {
return JSON.parse(settingsStorage.getItem(key)).access_token;
}
await initialize();
}
}
};

// Message socket opens
messaging.peerSocket.onopen = () => {
restoreSettings();
messaging.peerSocket.onopen = async () => {
await initialize();

// Generate new access/refresh token every 6 hours (default expiry is 8 hours)
setInterval(() => generateAccessToken(), 6 * 60 * 60 * 1000);
};

// Message socket receives data from app
messaging.peerSocket.onmessage = (event) => {
if (event.data.weight != null) {
fbApi.updateWeight(event.data.weight).then(() => {
messaging.peerSocket.onmessage = (evt) => {
if (evt.data.weight != null) {
fbApi.updateWeight(evt.data.weight).then(() => {
fetchWeightData();
});
}
if (event.data.water != null) {
fbApi.logWater(event.data.water).then(() => {
if (evt.data.water != null) {
fbApi.logWater(evt.data.water).then(() => {
fetchWaterData();
});
}
};

// Generate access token from stored refresh token
const generateAccessToken = async () => {
const refreshToken = settingsStorage.getItem('refresh_token');

if (refreshToken == null) {
// TODO: If no `refreshToken` exists, indicate to app that user is required to login
return;
}

let token = await getFitbitApiToken(refreshToken, 'refresh_token');

if (token.errors) {
//console.log(`[Companion] Error while generating access token ${JSON.stringify(token)}`);
settingsStorage.setItem('refresh_token', '');

// TODO: Indicate to app that user is required to login
return;
}

//console.log(`[Companion] Generated access token ${JSON.stringify(token)}`);

settingsStorage.setItem('access_token', token.access_token);
settingsStorage.setItem('refresh_token', token.refresh_token);

fbApi.setAccessToken(token.access_token);
};

// Initialise app
const initialize = async () => {
//console.log('Initializing app...');

try {
await generateAccessToken();
} catch (error) {
//console.log(`[Companion] Error while generating access token ${error}`);

// Re-try initialisation
setTimeout(() => initialize(), 10 * 1000);
return;
}

// Fetch current user
fbApi.getUser().then((result) => {
if (result.success) {
// Store current user display name
settingsStorage.setItem('current_user_name', result.display_name);

// Fetch weight and water data
fetchWeightData();
fetchWaterData();
}
});
};
55 changes: 55 additions & 0 deletions companion/oauth/base64_polyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';

function InvalidCharacterError(message) {
this.message = message;
}
InvalidCharacterError.prototype = new Error();
InvalidCharacterError.prototype.name = 'InvalidCharacterError';

export const btoa = function (input) {
var str = String(input);
for (
// initialize result and counter
var block, charCode, idx = 0, map = chars, output = '';
// if the next str index does not exist:
// change the mapping table to "="
// check if d has no fractional digits
str.charAt(idx | 0) || ((map = '='), idx % 1);
// "8 - idx % 1 * 8" generates the sequence 2, 4, 6, 8
output += map.charAt(63 & (block >> (8 - (idx % 1) * 8)))
) {
charCode = str.charCodeAt((idx += 3 / 4));
if (charCode > 0xff) {
throw new InvalidCharacterError(
"'btoa' failed: The string to be encoded contains characters outside of the Latin1 range."
);
}
block = (block << 8) | charCode;
}
return output;
};

export const atob = function (input) {
var str = String(input).replace(/[=]+$/, ''); // #31: ExtendScript bad parse of /=
if (str.length % 4 == 1) {
throw new InvalidCharacterError("'atob' failed: The string to be decoded is not correctly encoded.");
}
for (
// initialize result and counters
var bc = 0, bs, buffer, idx = 0, output = '';
// get next character
(buffer = str.charAt(idx++));
// character found in table? initialize bit storage and add its ascii value;
~buffer &&
((bs = bc % 4 ? bs * 64 + buffer : buffer),
// and if not first of each 4 characters,
// convert the first 8 bits to one ascii character
bc++ % 4)
? (output += String.fromCharCode(255 & (bs >> ((-2 * bc) & 6))))
: 0
) {
// try to find character in table (0-63, not found => -1)
buffer = chars.indexOf(buffer);
}
return output;
};
30 changes: 30 additions & 0 deletions companion/oauth/fitbit_oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { FITBIT_CLIENT_ID, FITBIT_CLIENT_SECRET, FITBIT_SCOPES } from '../../common/oauth.secret';
import { btoa } from './base64_polyfill';
import { urlEncode } from './url_encode';

export const getFitbitApiToken = async (exchangeCode: string, grantType = 'authorization_code') => {
const tokenRequest: RequestInit = {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: 'Basic ' + btoa(FITBIT_CLIENT_ID + ':' + FITBIT_CLIENT_SECRET),
},
body: urlEncode({
grant_type: grantType,
client_id: FITBIT_CLIENT_ID,
client_secret: FITBIT_CLIENT_SECRET,
scope: FITBIT_SCOPES,
code: grantType === 'authorization_code' ? exchangeCode : null,
refresh_token: grantType === 'refresh_token' ? exchangeCode : null,
redirect_uri: 'https://app-settings.fitbitdevelopercontent.com/simple-redirect.html',
}),
};

return await fetch('https://api.fitbit.com/oauth2/token', tokenRequest)
.then((data) => {
return data.json();
})
.catch((err) => {
//console.log('Error on token generation ' + err);
});
};
9 changes: 9 additions & 0 deletions companion/oauth/url_encode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const urlEncode = (object: object): string => {
let fBody = [];
for (let prop in object) {
let key = encodeURIComponent(prop);
let value = encodeURIComponent(object[prop]);
fBody.push(key + '=' + value);
}
return fBody.join('&');
};
41 changes: 27 additions & 14 deletions settings/index.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
import { CLIENT_ID, CLIENT_SECRET } from './oauth.secret';
import { FITBIT_CLIENT_ID, FITBIT_CLIENT_SECRET, FITBIT_SCOPES } from '../common/oauth.secret';

function mySettings(props) {
function mySettings(props: SettingsComponentProps) {
const refreshToken = props.settingsStorage.getItem('refresh_token');
const currentUserName = props.settingsStorage.getItem('current_user_name');
const isLoggedIn = refreshToken && currentUserName;
return (
<Page>
<Section
title={
<Text bold align="center">
Fitbit Account
Fitbit Log Settings
</Text>
}
>
<Oauth
settingsKey="oauth"
title="Login"
label="Fitbit"
status="Login"
authorizeUrl="https://www.fitbit.com/oauth2/authorize"
requestTokenUrl="https://api.fitbit.com/oauth2/token"
clientId={CLIENT_ID}
clientSecret={CLIENT_SECRET}
scope="weight nutrition"
/>
{isLoggedIn && <Text>Logged in as {currentUserName}</Text>}
{!isLoggedIn && (
<Oauth
settingsKey="oauth"
title="Fitbit Login"
label="Fitbit"
status={refreshToken ? 'Logged In' : 'Login'}
authorizeUrl="https://www.fitbit.com/oauth2/authorize"
requestTokenUrl="https://api.fitbit.com/oauth2/token"
clientId={FITBIT_CLIENT_ID}
clientSecret={FITBIT_CLIENT_SECRET}
scope={FITBIT_SCOPES}
onReturn={(data) => {
//console.log(`[Settings] Auth code received`);
props.settingsStorage.setItem('current_user_name', '');
props.settingsStorage.setItem('refresh_token', '');
props.settingsStorage.setItem('oauth_code', data.code);
}}
/>
)}
{isLoggedIn && <Button label="Logout" onClick={() => props.settingsStorage.setItem('refresh_token', '')} />}
</Section>
</Page>
);
Expand Down
2 changes: 0 additions & 2 deletions settings/oauth.secret.example.ts

This file was deleted.