feat(integrations): add Google Health (Fit) integration with full permissions
OAuth2 flow with all 11 Google Fitness scopes (activity, body, sleep, heart rate, nutrition, location, blood glucose/pressure/temperature, oxygen saturation, reproductive health). Stores access + refresh tokens; auto-refreshes on expiry. GoogleHealthSignalSource fetches steps, sleep sessions, active minutes, calories, and heart rate from the Fit aggregate + sessions APIs. Signals flow into both the tip orchestrator and the health-vitals pre-compute agent, which generates prompt snippets about step progress, sleep deficit, sedentary time, and elevated heart rate. Signal.kind extended with 'health'; IntegrationProvider extended with 'google-health'. Agent compute signal mapping enriched to include source, kind, and all features so health-vitals can filter its own signals. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,12 +6,13 @@ import { eq, and, gt, lt } from 'drizzle-orm';
|
||||
import { config } from '../config.js';
|
||||
import { getProfile, type Profile } from '../profile/builder.js';
|
||||
import { todoistSource } from '../signals/todoist.js';
|
||||
import { googleHealthSource } from '../signals/google-health.js';
|
||||
import { SignalAggregator } from '../signals/aggregator.js';
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
// Separate aggregator instance — avoids circular dep with recommender.ts.
|
||||
const _agentAggregator = new SignalAggregator().register(todoistSource);
|
||||
const _agentAggregator = new SignalAggregator().register(todoistSource).register(googleHealthSource);
|
||||
|
||||
// ── Internal auth helper ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -132,11 +133,16 @@ export async function computeAndStore(userId: string, agentId: string): Promise<
|
||||
const signals = await _agentAggregator.fetchAll(userId);
|
||||
tasks = signals.map((s) => ({
|
||||
id: s.id,
|
||||
source: s.source,
|
||||
kind: s.kind,
|
||||
content: s.content,
|
||||
// Task-specific fields (default to harmless values for non-task signals)
|
||||
priority: (s.features.priority as number) ?? 1,
|
||||
is_overdue: Boolean(s.features.is_overdue),
|
||||
task_age_days: (s.features.task_age_days as number) ?? 0,
|
||||
project_id: (s.metadata as Record<string, unknown>).project_id ?? null,
|
||||
// All features spread so source-specific agents (e.g. health-vitals) can read them
|
||||
...s.features,
|
||||
}));
|
||||
} catch {
|
||||
// No integration or fetch error — agents that need tasks will report "no tasks"
|
||||
|
||||
@@ -12,6 +12,24 @@ const TODOIST_OAUTH_URL = 'https://todoist.com/oauth/authorize';
|
||||
const TODOIST_TOKEN_URL = 'https://todoist.com/oauth/access_token';
|
||||
const TODOIST_SCOPES = 'data:read_write';
|
||||
|
||||
const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const GOOGLE_REVOKE_URL = 'https://oauth2.googleapis.com/revoke';
|
||||
|
||||
const GOOGLE_HEALTH_SCOPES = [
|
||||
'https://www.googleapis.com/auth/fitness.activity.read',
|
||||
'https://www.googleapis.com/auth/fitness.body.read',
|
||||
'https://www.googleapis.com/auth/fitness.sleep.read',
|
||||
'https://www.googleapis.com/auth/fitness.heart_rate.read',
|
||||
'https://www.googleapis.com/auth/fitness.nutrition.read',
|
||||
'https://www.googleapis.com/auth/fitness.location.read',
|
||||
'https://www.googleapis.com/auth/fitness.blood_glucose.read',
|
||||
'https://www.googleapis.com/auth/fitness.blood_pressure.read',
|
||||
'https://www.googleapis.com/auth/fitness.body_temperature.read',
|
||||
'https://www.googleapis.com/auth/fitness.oxygen_saturation.read',
|
||||
'https://www.googleapis.com/auth/fitness.reproductive_health.read',
|
||||
].join(' ');
|
||||
|
||||
// In-memory CSRF state store
|
||||
const pendingStates = new Map<string, { userId: string; redirectTo: string }>();
|
||||
|
||||
@@ -104,6 +122,96 @@ router.get('/todoist/callback', async (req: Request, res: Response) => {
|
||||
res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=todoist`);
|
||||
});
|
||||
|
||||
/** GET /api/integrations/google-health/connect — start Google Fit OAuth */
|
||||
router.get('/google-health/connect', requireAuth, (req: AuthenticatedRequest, res: Response) => {
|
||||
const state = nanoid();
|
||||
pendingStates.set(state, {
|
||||
userId: req.userId!,
|
||||
redirectTo: (req.query.redirectTo as string) ?? '/connect',
|
||||
});
|
||||
setTimeout(() => pendingStates.delete(state), 10 * 60 * 1000);
|
||||
|
||||
const url = new URL(GOOGLE_AUTH_URL);
|
||||
url.searchParams.set('client_id', config.GOOGLE_CLIENT_ID);
|
||||
url.searchParams.set('redirect_uri', `${config.API_BASE_URL}/api/integrations/google-health/callback`);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('scope', GOOGLE_HEALTH_SCOPES);
|
||||
url.searchParams.set('state', state);
|
||||
url.searchParams.set('access_type', 'offline');
|
||||
url.searchParams.set('prompt', 'consent');
|
||||
|
||||
res.redirect(url.toString());
|
||||
});
|
||||
|
||||
/** GET /api/integrations/google-health/callback — Google returns here */
|
||||
router.get('/google-health/callback', async (req: Request, res: Response) => {
|
||||
const state = req.query.state as string;
|
||||
const code = req.query.code as string;
|
||||
const error = req.query.error as string | undefined;
|
||||
|
||||
if (error) {
|
||||
res.status(400).json({ error: `Google denied access: ${error}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = pendingStates.get(state);
|
||||
if (!pending) {
|
||||
res.status(400).json({ error: 'Invalid or expired state' });
|
||||
return;
|
||||
}
|
||||
pendingStates.delete(state);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.GOOGLE_CLIENT_ID,
|
||||
client_secret: config.GOOGLE_CLIENT_SECRET,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
redirect_uri: `${config.API_BASE_URL}/api/integrations/google-health/callback`,
|
||||
});
|
||||
|
||||
const tokenRes = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
const detail = await tokenRes.text().catch(() => '');
|
||||
res.status(502).json({ error: `Failed to exchange Google token: ${detail}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenData = (await tokenRes.json()) as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + tokenData.expires_in * 1000).toISOString();
|
||||
|
||||
await db
|
||||
.delete(integrationTokens)
|
||||
.where(
|
||||
and(
|
||||
eq(integrationTokens.userId, pending.userId),
|
||||
eq(integrationTokens.provider, 'google-health'),
|
||||
),
|
||||
);
|
||||
await db.insert(integrationTokens).values({
|
||||
id: nanoid(),
|
||||
userId: pending.userId,
|
||||
provider: 'google-health',
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token ?? null,
|
||||
expiresAt,
|
||||
tokenStatus: 'active',
|
||||
connectedAt: now.toISOString(),
|
||||
});
|
||||
|
||||
res.redirect(`${config.WEB_BASE_URL}${pending.redirectTo}?connected=google-health`);
|
||||
});
|
||||
|
||||
/** DELETE /api/integrations/:provider — revoke token */
|
||||
router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const provider = String(req.params.provider);
|
||||
@@ -120,13 +228,16 @@ router.delete('/:provider', requireAuth, async (req: AuthenticatedRequest, res:
|
||||
.limit(1);
|
||||
|
||||
if (token?.provider === 'todoist') {
|
||||
// Best-effort revocation
|
||||
await fetch('https://api.todoist.com/sync/v9/access_tokens/revoke', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token.accessToken}` },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
if (token?.provider === 'google-health') {
|
||||
await fetch(`${GOOGLE_REVOKE_URL}?token=${token.accessToken}`, { method: 'POST' }).catch(() => {});
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(integrationTokens)
|
||||
.where(
|
||||
|
||||
@@ -10,6 +10,7 @@ import { bus } from '../events/bus.js';
|
||||
import type { TipCandidate, Signal } from '@oo/shared-types';
|
||||
import { todoistSource, dueAgeDays } from '../signals/todoist.js';
|
||||
export { dueAgeDays };
|
||||
import { googleHealthSource } from '../signals/google-health.js';
|
||||
import { SignalAggregator } from '../signals/aggregator.js';
|
||||
import { getActiveAgentOutputs } from './agent-outputs.js';
|
||||
import { getEligibleAgentIds } from '../profile/eligibility.js';
|
||||
@@ -19,8 +20,11 @@ const router: ExpressRouter = Router();
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal aggregator — register sources here as new integrations are added
|
||||
// ---------------------------------------------------------------------------
|
||||
export const aggregator = new SignalAggregator().register(todoistSource);
|
||||
export const _clearSignalCacheForTests = () => todoistSource.clearCache();
|
||||
export const aggregator = new SignalAggregator().register(todoistSource).register(googleHealthSource);
|
||||
export const _clearSignalCacheForTests = () => {
|
||||
todoistSource.clearCache();
|
||||
googleHealthSource.clearCache();
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Signal → TipCandidate conversion
|
||||
|
||||
312
services/api/src/signals/google-health.ts
Normal file
312
services/api/src/signals/google-health.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import type { Signal, SignalSource } from '@oo/shared-types';
|
||||
import { db } from '../db/index.js';
|
||||
import { integrationTokens } from '../db/schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { bus } from '../events/bus.js';
|
||||
import { config } from '../config.js';
|
||||
import { logger } from '../logger.js';
|
||||
|
||||
const CACHE_TTL_MS = 5 * 60_000;
|
||||
const FIT_AGGREGATE_URL = 'https://www.googleapis.com/fitness/v1/users/me/dataset:aggregate';
|
||||
const FIT_SESSIONS_URL = 'https://www.googleapis.com/fitness/v1/users/me/sessions';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
const STEP_DAILY_GOAL = 7_000;
|
||||
const SLEEP_GOAL_HOURS = 7;
|
||||
|
||||
interface FitBucket {
|
||||
dataset: Array<{
|
||||
dataSourceId: string;
|
||||
point: Array<{ value: Array<{ intVal?: number; fpVal?: number }> }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface FitAggregateResponse {
|
||||
bucket?: FitBucket[];
|
||||
}
|
||||
|
||||
interface FitSession {
|
||||
name: string;
|
||||
startTimeMillis: string;
|
||||
endTimeMillis: string;
|
||||
activityType: number;
|
||||
}
|
||||
|
||||
interface FitSessionsResponse {
|
||||
session?: FitSession[];
|
||||
}
|
||||
|
||||
async function refreshGoogleToken(
|
||||
userId: string,
|
||||
refreshToken: string,
|
||||
): Promise<string | null> {
|
||||
const body = new URLSearchParams({
|
||||
client_id: config.GOOGLE_CLIENT_ID,
|
||||
client_secret: config.GOOGLE_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token',
|
||||
});
|
||||
|
||||
const res = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = (await res.json()) as { access_token: string; expires_in: number };
|
||||
const expiresAt = new Date(Date.now() + data.expires_in * 1000).toISOString();
|
||||
|
||||
await db
|
||||
.update(integrationTokens)
|
||||
.set({ accessToken: data.access_token, expiresAt, tokenStatus: 'active' })
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
function todayMidnightMs(): number {
|
||||
const d = new Date();
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d.getTime();
|
||||
}
|
||||
|
||||
function yesterdayIso(): string {
|
||||
return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
async function fetchAggregates(
|
||||
token: string,
|
||||
startMs: number,
|
||||
endMs: number,
|
||||
): Promise<FitAggregateResponse> {
|
||||
const res = await fetch(FIT_AGGREGATE_URL, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aggregateBy: [
|
||||
{ dataTypeName: 'com.google.step_count.delta' },
|
||||
{ dataTypeName: 'com.google.calories.expended' },
|
||||
{ dataTypeName: 'com.google.active_minutes' },
|
||||
{ dataTypeName: 'com.google.heart_rate.bpm' },
|
||||
],
|
||||
bucketByTime: { durationMillis: endMs - startMs },
|
||||
startTimeMillis: String(startMs),
|
||||
endTimeMillis: String(endMs),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Fit aggregate: ${res.status}`);
|
||||
return res.json() as Promise<FitAggregateResponse>;
|
||||
}
|
||||
|
||||
async function fetchSleepSessions(token: string): Promise<FitSessionsResponse> {
|
||||
const url = new URL(FIT_SESSIONS_URL);
|
||||
url.searchParams.set('activityType', '72');
|
||||
url.searchParams.set('startTime', yesterdayIso());
|
||||
url.searchParams.set('endTime', new Date().toISOString());
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Fit sessions: ${res.status}`);
|
||||
return res.json() as Promise<FitSessionsResponse>;
|
||||
}
|
||||
|
||||
function extractMetric(
|
||||
bucket: FitBucket,
|
||||
dataTypeName: string,
|
||||
valueKey: 'intVal' | 'fpVal',
|
||||
): number {
|
||||
for (const ds of bucket.dataset) {
|
||||
if (!ds.dataSourceId.includes(dataTypeName.replace('com.google.', '').replace('.', '_'))) continue;
|
||||
for (const pt of ds.point) {
|
||||
const v = pt.value[0];
|
||||
if (v) return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function extractAnyMetric(
|
||||
bucket: FitBucket,
|
||||
typeSuffix: string,
|
||||
valueKey: 'intVal' | 'fpVal',
|
||||
): number {
|
||||
for (const ds of bucket.dataset) {
|
||||
if (!ds.dataSourceId.includes(typeSuffix)) continue;
|
||||
const pt = ds.point[0];
|
||||
if (pt?.value[0]) {
|
||||
const v = pt.value[0];
|
||||
return valueKey === 'intVal' ? (v.intVal ?? 0) : (v.fpVal ?? 0);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export class GoogleHealthSignalSource implements SignalSource {
|
||||
readonly id = 'google-health';
|
||||
|
||||
private cache = new Map<string, { signals: Signal[]; fetchedAt: number }>();
|
||||
|
||||
clearCache(userId?: string): void {
|
||||
if (userId) this.cache.delete(userId);
|
||||
else this.cache.clear();
|
||||
}
|
||||
|
||||
async fetchSignals(userId: string): Promise<Signal[]> {
|
||||
const entry = this.cache.get(userId);
|
||||
if (entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS) return entry.signals;
|
||||
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(integrationTokens)
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return [];
|
||||
|
||||
let token = row.accessToken;
|
||||
const isExpired = row.expiresAt && new Date(row.expiresAt).getTime() - Date.now() < 5 * 60_000;
|
||||
|
||||
if (isExpired && row.refreshToken) {
|
||||
const refreshed = await refreshGoogleToken(userId, row.refreshToken);
|
||||
if (!refreshed) {
|
||||
logger.warn({ userId }, 'google-health: refresh failed');
|
||||
await db
|
||||
.update(integrationTokens)
|
||||
.set({ tokenStatus: 'needs_reconnect' })
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
|
||||
bus.publish('signals.integration.token_expired', {
|
||||
userId,
|
||||
provider: 'google-health',
|
||||
detectedAt: new Date().toISOString(),
|
||||
});
|
||||
return entry?.signals ?? [];
|
||||
}
|
||||
token = refreshed;
|
||||
}
|
||||
|
||||
try {
|
||||
const startMs = todayMidnightMs();
|
||||
const endMs = Date.now();
|
||||
|
||||
const [aggData, sleepData] = await Promise.all([
|
||||
fetchAggregates(token, startMs, endMs),
|
||||
fetchSleepSessions(token),
|
||||
]);
|
||||
|
||||
const bucket = aggData.bucket?.[0];
|
||||
const signals: Signal[] = [];
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (bucket) {
|
||||
// Steps
|
||||
const steps = extractAnyMetric(bucket, 'step_count', 'intVal');
|
||||
const stepGoalPct = Math.round((steps / STEP_DAILY_GOAL) * 100);
|
||||
signals.push({
|
||||
id: `google-health:steps`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `${steps.toLocaleString()} steps today (${stepGoalPct}% of ${STEP_DAILY_GOAL.toLocaleString()} goal)`,
|
||||
metadata: { dataType: 'steps' },
|
||||
features: {
|
||||
step_count: steps,
|
||||
step_goal_pct: stepGoalPct,
|
||||
step_goal: STEP_DAILY_GOAL,
|
||||
below_step_goal: steps < STEP_DAILY_GOAL,
|
||||
},
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
// Calories + active minutes
|
||||
const calories = Math.round(extractAnyMetric(bucket, 'calories', 'fpVal'));
|
||||
const activeMinutes = extractAnyMetric(bucket, 'active_minutes', 'intVal');
|
||||
signals.push({
|
||||
id: `google-health:activity`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `${activeMinutes} active minutes, ${calories} calories burned today`,
|
||||
metadata: { dataType: 'activity' },
|
||||
features: {
|
||||
active_minutes: activeMinutes,
|
||||
calories_burned: calories,
|
||||
sedentary: activeMinutes < 20,
|
||||
},
|
||||
timestamp: now,
|
||||
});
|
||||
|
||||
// Heart rate
|
||||
const bpm = Math.round(extractAnyMetric(bucket, 'heart_rate', 'fpVal'));
|
||||
if (bpm > 0) {
|
||||
signals.push({
|
||||
id: `google-health:heart_rate`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `Resting heart rate: ${bpm} bpm`,
|
||||
metadata: { dataType: 'heart_rate' },
|
||||
features: { resting_bpm: bpm, elevated_hr: bpm > 90 },
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep — find the most recent sleep session
|
||||
if (sleepData.session?.length) {
|
||||
const sorted = [...sleepData.session].sort(
|
||||
(a, b) => Number(b.endTimeMillis) - Number(a.endTimeMillis),
|
||||
);
|
||||
const last = sorted[0]!;
|
||||
const durationMs = Number(last.endTimeMillis) - Number(last.startTimeMillis);
|
||||
const sleepHours = Math.round((durationMs / 3_600_000) * 10) / 10;
|
||||
const belowGoal = sleepHours < SLEEP_GOAL_HOURS;
|
||||
signals.push({
|
||||
id: `google-health:sleep`,
|
||||
source: 'google-health',
|
||||
kind: 'health',
|
||||
content: `${sleepHours}h sleep last night (${belowGoal ? 'below' : 'meets'} ${SLEEP_GOAL_HOURS}h goal)`,
|
||||
metadata: { dataType: 'sleep', sessionName: last.name },
|
||||
features: {
|
||||
sleep_hours: sleepHours,
|
||||
sleep_goal_hours: SLEEP_GOAL_HOURS,
|
||||
sleep_deficit_hours: Math.max(0, SLEEP_GOAL_HOURS - sleepHours),
|
||||
below_sleep_goal: belowGoal,
|
||||
},
|
||||
timestamp: now,
|
||||
});
|
||||
}
|
||||
|
||||
this.cache.set(userId, { signals, fetchedAt: Date.now() });
|
||||
bus.publish('signals.task.synced', {
|
||||
userId,
|
||||
source: 'google-health',
|
||||
count: signals.length,
|
||||
syncedAt: now,
|
||||
});
|
||||
|
||||
return signals;
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { message?: string }).message;
|
||||
if (status?.includes('401')) {
|
||||
logger.warn({ userId }, 'google-health: token expired (401)');
|
||||
if (row.refreshToken) {
|
||||
await refreshGoogleToken(userId, row.refreshToken);
|
||||
} else {
|
||||
await db
|
||||
.update(integrationTokens)
|
||||
.set({ tokenStatus: 'needs_reconnect' })
|
||||
.where(and(eq(integrationTokens.userId, userId), eq(integrationTokens.provider, 'google-health')));
|
||||
bus.publish('signals.integration.token_expired', {
|
||||
userId,
|
||||
provider: 'google-health',
|
||||
detectedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.error({ userId, err }, 'google-health: fetch failed');
|
||||
}
|
||||
return entry?.signals ?? [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const googleHealthSource = new GoogleHealthSignalSource();
|
||||
Reference in New Issue
Block a user