Skip to content
Merged
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
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,40 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Fixed

- Connecting Google Fit from a second profile no longer dead-ends: the
Connect wizard was silently skipping the credentials step when a global
`fit_credentials.json` already existed, and a Google account not listed
as a Test user of the project hit "Error 403: access_denied" in the
browser — with the CLI hanging forever since Google never redirects back.
The OAuth flow now times out after 5 minutes with a message explaining
the Test-user requirement.
- OAuth client-secrets JSON of type "Web application" is now rejected up
front with instructions to recreate it as a Desktop app client (it can
never complete the local sign-in flow).
- When the saved token expires (Testing-mode projects expire refresh tokens
every ~7 days), Connect now falls through to the browser flow instead of
failing with a cryptic `invalid_grant`; sync error messages explain the
7-day cause.

### Changed

- The Google Fit Connect wizard is transparent about which OAuth client is
in use: it shows the client ID, project and file path, and offers to
reuse it (signing in with any Test-user account) or supply a different
OAuth JSON for the current profile only.
- Google Fit credentials can now be set per profile: an optional
`profiles/{slug}/fit_credentials.json` takes precedence over the shared
global file (`GOOGLE_CREDENTIALS_FILE` still wins over both).
- OAuth failures are mapped to actionable messages (access denied → add the
account as a Test user; timeout; web-type client; expired token) in both
English and pt_BR, and the setup instructions now stress adding every
connecting Google account as a Test user and the weekly Testing-mode
expiry.

## [0.4.2] - 2026-07-10

### Fixed
Expand Down
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ reinstalls never touch your data:
| Contents | Location | Override |
|---|---|---|
| Profiles, databases, exports | `~/.local/share/lifter/` | `$XDG_DATA_HOME` |
| `.env` (API keys), `fit_credentials.json` | `~/.config/lifter/` | `$XDG_CONFIG_HOME` |
| `.env` (API keys), `fit_credentials.json` (shared OAuth client) | `~/.config/lifter/` | `$XDG_CONFIG_HOME` |
| Debug logs, chat history | `~/.local/state/lifter/` | `$XDG_STATE_HOME` |

Setting `LIFTER_HOME=/some/dir` forces all three into a single directory
Expand Down Expand Up @@ -316,9 +316,12 @@ Adds sleep, steps, calories, and heart rate data to your analytics and AI contex
4. **APIs & Services → OAuth consent screen**
- User type: External
- Fill in app name (anything, e.g. "lifter")
- Add your Gmail as a **Test user** → Save
- Add **every Google account you'll connect from any profile** as a **Test user**
(up to 100) → Save. An account that isn't listed gets **Error 403: access_denied**
in the browser when it tries to connect.
5. **APIs & Services → Credentials → Create Credentials → OAuth client ID**
- Application type: **Desktop app**
- Application type: **Desktop app** — ⚠ **not** "Web application"; a web client
cannot complete the local sign-in flow
- Name: anything
- Click Create
6. **Download JSON** → Google will download a file named something like
Expand All @@ -332,12 +335,22 @@ In the menu: **Google Fit → Connect / re-authenticate**

Lifter asks for the path to the downloaded JSON (first time only), then a browser window opens. Sign in with the Gmail you added as a test user and approve the fitness permissions. The token is saved per profile as `fit_token.json` and reused automatically.

If an OAuth client is already configured (e.g. by another profile), Connect shows which client/project is in use and lets you either reuse it — sign in with any account added as a Test user — or supply a different OAuth JSON just for this profile.

### Step 3 — Sync

**Google Fit → Sync health data → 30 days**

After syncing, the recovery score appears in the header and the AI coach uses your sleep and HR data in all suggestions.

### Multiple profiles

All profiles share the OAuth client saved at `~/.config/lifter/fit_credentials.json` by default — each profile just signs in with its own Google account (add every account as a Test user, step 1.4). To use a completely separate Google Cloud project for one profile, choose **"Use a different OAuth JSON"** during Connect; it is stored at `~/.local/share/lifter/profiles/{slug}/fit_credentials.json` and takes precedence over the shared one for that profile.

### Token expires weekly

While the Google Cloud project is in **Testing** publishing status, Google expires the connection roughly every **7 days**. Lifter detects this and asks you to reconnect — just run **Google Fit → Connect** again. To get rid of the weekly re-auth, publish the app in Cloud Console (**OAuth consent screen → Publish app**); Google shows an "unverified app" warning screen during sign-in, which is fine for personal use.

### Samsung Health users

Samsung Health syncs to Google Fit by default on Android. Enable it in the Samsung Health app under **Settings → Connected services → Google Fit**.
Expand Down Expand Up @@ -643,7 +656,7 @@ Each profile stores its own settings that are set through the in-app menus:
| `slug` | URL-safe identifier used as the directory name |
| `hevy_api_key` | API key for this profile's Hevy account |

Profile databases live at `~/.local/share/lifter/profiles/{slug}/hevy.db` and Google Fit tokens at `~/.local/share/lifter/profiles/{slug}/fit_token.json`.
Profile databases live at `~/.local/share/lifter/profiles/{slug}/hevy.db` and Google Fit tokens at `~/.local/share/lifter/profiles/{slug}/fit_token.json`. A profile may also have its own `fit_credentials.json` in the same directory (optional — overrides the shared OAuth client in `~/.config/lifter/`).

All in-app preferences are stored in the `user_preferences` table in each profile's database, not in `.env`. Notable keys: `units`, `auto_sync`, `sync_stale_hours`, `goals_checkin_days`, `default_stats_weeks`, `report_weeks`, `ui_language`, `ai_provider` / `ai_model` (per-profile override of `.env`), `ai_send_name` / `ai_send_body` (privacy), `ai_tokens_month_budget`, `memories_max`, `debug_logging`.

Expand Down
106 changes: 85 additions & 21 deletions commands/fit.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,32 +108,90 @@ def _do_fit() -> None:
console.print(_("fit.disconnected"))


def _prompt_credentials_json(dest: Path, saved_key: str) -> bool:
"""Ask for a downloaded client-secrets JSON, validate it and copy to dest.

Returns True when a valid Desktop-app ("installed") JSON was saved."""
raw = questionary.path(_("fit.credentials_path_prompt"), style=STYLE).ask()
if not raw or not raw.strip():
return False
source = Path(raw.strip()).expanduser()
try:
payload = json.loads(source.read_text(encoding="utf-8"))
assert isinstance(payload, dict) and ("installed" in payload or "web" in payload)
except Exception:
console.print(_("fit.credentials_invalid"))
return False
if "installed" not in payload:
console.print(_("fit.credentials_web_client"))
return False
import shutil as _shutil

import paths as _paths

_paths.ensure_dirs()
_shutil.copy2(source, dest)
dest.chmod(0o600)
console.print(_(saved_key, path=_esc(str(dest))))
return True


def _fit_setup() -> None:
from fit.auth import credentials_file
import paths as _paths
from fit.auth import credentials_file, describe_client, profile_credentials_file

console.rule(_("fit.connect_rule"))
console.print(_("fit.setup_instructions"))

if not credentials_file().exists():
raw = questionary.path(_("fit.credentials_path_prompt"), style=STYLE).ask()
if not raw or not raw.strip():
return
source = Path(raw.strip()).expanduser()
try:
payload = json.loads(source.read_text(encoding="utf-8"))
assert isinstance(payload, dict) and ("installed" in payload or "web" in payload)
except Exception:
creds_path = credentials_file()
client = describe_client(creds_path) if creds_path.exists() else None

if client is None:
# First-ever setup (or unreadable file): full instructions + JSON prompt.
# An existing-but-broken file is replaced in place so resolution still finds it.
console.print(_("fit.setup_instructions"))
if creds_path.exists():
console.print(_("fit.credentials_invalid"))
dest = creds_path if creds_path.exists() else _paths.FIT_CREDENTIALS_FILE
if not _prompt_credentials_json(dest, "fit.credentials_saved"):
return
import shutil as _shutil

import paths as _paths

_paths.ensure_dirs()
_shutil.copy2(source, _paths.FIT_CREDENTIALS_FILE)
_paths.FIT_CREDENTIALS_FILE.chmod(0o600)
console.print(_("fit.credentials_saved", path=_esc(str(_paths.FIT_CREDENTIALS_FILE))))
elif client["type"] == "web":
# A web-type client can never complete the loopback flow for anyone —
# replace it in place with a proper Desktop-app JSON.
console.print(_("fit.credentials_web_client"))
console.print(_("fit.setup_instructions"))
if not _prompt_credentials_json(creds_path, "fit.credentials_saved"):
return
else:
console.print(
Panel(
_(
"fit.client_in_use",
client_id=_esc(client["client_id"]),
project_id=_esc(client["project_id"]),
path=_esc(str(creds_path)),
),
border_style="cyan",
padding=(0, 2),
)
)
choice = questionary.select(
_("fit.reuse_prompt"),
choices=[
questionary.Choice(_("fit.reuse_choice_existing"), value="existing"),
questionary.Choice(_("fit.reuse_choice_new"), value="new"),
questionary.Choice(_("fit.reuse_choice_cancel"), value="cancel"),
],
style=STYLE,
).ask()
if choice in (None, "cancel"):
return
if choice == "new":
console.print(_("fit.setup_instructions"))
if not _prompt_credentials_json(profile_credentials_file(), "fit.credentials_saved_profile"):
return

console.print(_("fit.test_user_reminder"))
console.print(_("fit.testing_mode_note"))
if not questionary.confirm(_("fit.ready_to_auth"), default=True, style=STYLE).ask():
return

Expand All @@ -148,9 +206,15 @@ def _fit_setup() -> None:
_dlog("ERROR", "Google Fit connect failed: credentials file not found")
console.print(f"\n[red]{e}[/red]") # safe: our own message, no secrets
except Exception as e:
from fit.auth import _FLOW_TIMEOUT_S, classify_auth_error

_dlog("ERROR", f"Google Fit connect failed: {type(e).__name__}", error=str(e)[:200])
console.print(_("error.fit_auth_failed"))
console.print(f"[dim]{type(e).__name__}[/dim]")
key = classify_auth_error(e)
if key:
console.print(_(key, minutes=_FLOW_TIMEOUT_S // 60))
else:
console.print(_("error.fit_auth_failed"))
console.print(f"[dim]{type(e).__name__}: {_esc(str(e)[:150])}[/dim]")


def _render_fit_dashboard() -> None:
Expand Down
86 changes: 74 additions & 12 deletions fit/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Google OAuth flow for the Fitness API."""

import json
import os
from pathlib import Path
from typing import Any
Expand All @@ -15,21 +16,70 @@
]


def profile_credentials_file() -> Path:
"""Optional per-profile client-secrets file, next to the profile's DB."""
return config.DB_PATH.parent / "fit_credentials.json"


def credentials_file() -> Path:
"""OAuth client-secrets location, resolved at call time so a file copied
in via the in-app setup is picked up without restarting."""
in via the in-app setup is picked up without restarting.

Resolution order: GOOGLE_CREDENTIALS_FILE env → per-profile file →
global file shared by all profiles."""
raw = os.environ.get("GOOGLE_CREDENTIALS_FILE", "")
if raw:
p = Path(raw).expanduser()
return p if p.is_absolute() else paths.CONFIG_DIR / p
pcf = profile_credentials_file()
if pcf.exists():
return pcf
return paths.FIT_CREDENTIALS_FILE


def describe_client(path: Path) -> dict | None:
"""{'client_id', 'project_id', 'type'} from a client-secrets JSON, or None."""
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return None
kind = "installed" if "installed" in payload else "web" if "web" in payload else None
if kind is None:
return None
info = payload.get(kind) or {}
return {
"client_id": info.get("client_id", "?"),
"project_id": info.get("project_id", "?"),
"type": kind,
}
except Exception:
return None


def classify_auth_error(e: BaseException) -> str | None:
"""Map an OAuth-flow exception to an i18n error key, or None if unknown.

Matches by class name (not isinstance) so tests and callers don't need
oauthlib/google-auth imported."""
msg = str(e).lower()
names = {c.__name__ for c in type(e).__mro__}
if "AccessDeniedError" in names or "access_denied" in msg:
return "error.fit_access_denied"
if "WSGITimeoutError" in names or isinstance(e, AttributeError):
return "error.fit_auth_timeout"
if "redirect_uri_mismatch" in msg or (isinstance(e, ValueError) and "client secrets" in msg):
return "error.fit_web_client"
if "invalid_grant" in msg or "RefreshError" in names:
return "error.fit_token_refresh_expired"
return None


def _token_file() -> Path:
return config.DB_PATH.parent / "fit_token.json"


_REFRESH_TIMEOUT_S = 20
_FLOW_TIMEOUT_S = 300


def refresh_transport() -> Any:
Expand All @@ -56,6 +106,19 @@ def _write_token(creds: Any) -> None:
) from e


def _run_browser_flow() -> Any:
creds_file = credentials_file()
if not creds_file.exists():
raise FileNotFoundError(
f"Google OAuth credentials file not found at '{creds_file}'.\n"
"Follow the setup instructions in the menu to create one."
)
from google_auth_oauthlib.flow import InstalledAppFlow

flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), SCOPES)
return flow.run_local_server(port=0, open_browser=True, timeout_seconds=_FLOW_TIMEOUT_S)


def get_credentials() -> Any:
"""Return valid Google credentials, running the OAuth flow if needed."""
from google.oauth2.credentials import Credentials
Expand All @@ -67,18 +130,17 @@ def get_credentials() -> Any:

if not creds or not creds.valid:
if creds and creds.expired and creds.refresh_token:
creds.refresh(refresh_transport())
from google.auth.exceptions import RefreshError

try:
creds.refresh(refresh_transport())
except RefreshError:
# Testing-mode projects expire refresh tokens after ~7 days;
# drop the stale token and re-run the full browser flow.
disconnect()
creds = _run_browser_flow()
else:
creds_file = credentials_file()
if not creds_file.exists():
raise FileNotFoundError(
f"Google OAuth credentials file not found at '{creds_file}'.\n"
"Follow the setup instructions in the menu to create one."
)
from google_auth_oauthlib.flow import InstalledAppFlow

flow = InstalledAppFlow.from_client_secrets_file(str(creds_file), SCOPES)
creds = flow.run_local_server(port=0, open_browser=True)
creds = _run_browser_flow()

_write_token(creds)

Expand Down
6 changes: 4 additions & 2 deletions fit/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ def _headers(self) -> dict:
except RefreshError as e:
self._disconnect()
raise RuntimeError(
"Google Fit token expired and could not be refreshed.\n"
"Google Fit token expired and could not be refreshed — projects in "
"Testing mode expire tokens every ~7 days.\n"
"Go to Menu → Google Fit → Connect to re-authenticate."
) from e
return {"Authorization": f"Bearer {self._creds.token}"}
Expand All @@ -36,7 +37,8 @@ def _check(self, resp: httpx.Response, operation: str) -> None:
if resp.status_code == 401:
self._disconnect()
raise RuntimeError(
"Google Fit session expired. Token has been cleared — "
"Google Fit session expired (Testing-mode projects expire tokens every "
"~7 days). Token has been cleared — "
"go to Menu → Google Fit → Connect to re-authenticate. (error 401)"
)
if resp.status_code == 403:
Expand Down
Loading