Compare commits
3 Commits
717ebf0e82
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| fd43bede6e | |||
| 3e6ef7a908 | |||
| 07804e8ae8 |
@@ -16,6 +16,8 @@ token helper, and the Home Assistant custom component itself (see Integration).
|
|||||||
endpoints are mapped.
|
endpoints are mapped.
|
||||||
- `get-token.py` — logs in and prints a bearer token to stdout (stdlib only).
|
- `get-token.py` — logs in and prints a bearer token to stdout (stdlib only).
|
||||||
- `custom_components/west_wood_club/` — the Home Assistant integration.
|
- `custom_components/west_wood_club/` — the Home Assistant integration.
|
||||||
|
Its `brand/` dir holds the integration icon/logo, extracted from the capture's
|
||||||
|
`westwoodclub-ie.png` asset.
|
||||||
- `android-flows.mitm` — mitmproxy capture of the app's traffic. **Gitignored and
|
- `android-flows.mitm` — mitmproxy capture of the app's traffic. **Gitignored and
|
||||||
untracked**: it contains real credentials and a bearer token in cleartext. Never
|
untracked**: it contains real credentials and a bearer token in cleartext. Never
|
||||||
commit it or copy its secrets into tracked files.
|
commit it or copy its secrets into tracked files.
|
||||||
@@ -50,8 +52,9 @@ Python uses **single-quoted strings** (`'...'`). Reformat with
|
|||||||
`nix run nixpkgs#ruff`). Docstrings stay triple-double-quoted (`"""`).
|
`nix run nixpkgs#ruff`). Docstrings stay triple-double-quoted (`"""`).
|
||||||
|
|
||||||
In prose (commit messages, docs, comments), backtick-quote anything code-like —
|
In prose (commit messages, docs, comments), backtick-quote anything code-like —
|
||||||
paths, filenames, identifiers, commands, endpoints — rather than plain or
|
paths, filenames, identifiers, commands, endpoints, HTTP headers, field/JSON
|
||||||
double-quoted text.
|
keys, UUIDs/IDs, env vars — rather than plain or double-quoted text. If in doubt
|
||||||
|
and it's a literal token from code or an API, backtick it.
|
||||||
|
|
||||||
## Working with the capture
|
## Working with the capture
|
||||||
|
|
||||||
@@ -61,23 +64,62 @@ Read flows with the mitmproxy Python API:
|
|||||||
from mitmproxy.io import FlowReader
|
from mitmproxy.io import FlowReader
|
||||||
from mitmproxy.http import HTTPFlow
|
from mitmproxy.http import HTTPFlow
|
||||||
|
|
||||||
with open("/abs/path/android-flows.mitm", "rb") as f:
|
with open('/abs/path/android-flows.mitm', 'rb') as f:
|
||||||
for flow in FlowReader(f).stream():
|
for flow in FlowReader(f).stream():
|
||||||
if isinstance(flow, HTTPFlow) and "perfectgym.com" in flow.request.host:
|
if isinstance(flow, HTTPFlow) and 'perfectgym.com' in flow.request.host:
|
||||||
... # flow.request / flow.response
|
... # flow.request / flow.response
|
||||||
```
|
```
|
||||||
|
|
||||||
When dumping flows, redact `Authorization` / `Cookie` headers and the login body
|
When dumping flows, redact `Authorization` / `Cookie` headers and the login body
|
||||||
(email + password) before writing anything to a tracked file.
|
(email + password) before writing anything to a tracked file.
|
||||||
|
|
||||||
|
Some questions the capture can't answer (token lifecycle, the white-label ID,
|
||||||
|
error codes) need the app itself. If you're pointed at **decompiled APK output**
|
||||||
|
(e.g. apktool `smali/`), grep it there — but it's R8-obfuscated: class names are
|
||||||
|
mangled and library types (e.g. OkHttp, `androidx.security.crypto`) may be
|
||||||
|
shrunk/repackaged, so a missing grep hit is not proof of absence. No such
|
||||||
|
decompilation lives in this repo (the user can supply a local apktool dump on
|
||||||
|
request; app package `com.perfectgym.perfectgymgo2.westwoodclub`). The original
|
||||||
|
ELPassion source package names survive obfuscation under
|
||||||
|
`smali/com/elpassion/perfectgym/`, which is the useful entry point.
|
||||||
|
|
||||||
|
### How the app stores the bearer token
|
||||||
|
|
||||||
|
Confirmed from the decompilation (relevant because it answers the token-lifecycle
|
||||||
|
question and shows there's no second auth secret to capture):
|
||||||
|
|
||||||
|
- The login response DTO (`AccountAuthorizationGoApiDto`) carries `token`,
|
||||||
|
`tokenType`, `authorizationHeader`, and a **nullable `expireTime`**.
|
||||||
|
`DtoMapperKt.asAuthorizeResponse` keeps **only the bare `token`** string;
|
||||||
|
`tokenType`/`authorizationHeader`/`expireTime` are discarded. The app
|
||||||
|
reconstructs `Authorization: bearer <token>` itself per request.
|
||||||
|
- The token is **persisted in `EncryptedSharedPreferences`** (androidx
|
||||||
|
security-crypto, R8-repackaged to `l3.*`; AES-256-GCM master key in the Android
|
||||||
|
Keystore). The store class is `f6/o` (interface `f6/p`); the backing file is
|
||||||
|
named `wevgebvre` and values are Moshi-JSON-encoded. Token key: **`"token"`**
|
||||||
|
(writer `f6/o.h(String)`, reader `f6/o.r()`).
|
||||||
|
- **Load path:** at DI-graph construction the provider (`androidx/room/v0`) calls
|
||||||
|
`f6/p.r()` and passes the stored token into the `appmodel/s0` AppModel
|
||||||
|
constructor as its initial value, which seeds the reactive `tokenS`
|
||||||
|
(`Optional<String>`) stream — so the app comes up already authenticated.
|
||||||
|
Login writes the new token back via `f6/p.h(...)` (dispatcher `z4/c`).
|
||||||
|
- **Legacy + migration:** older builds kept the same `"token"` key in the
|
||||||
|
*plaintext* default `SharedPreferences` (`f6/b0`, via
|
||||||
|
`PreferenceManager.getDefaultSharedPreferences`). `PerfectGymApplication` runs a
|
||||||
|
one-time migration gated by an `isMigrated` flag: copy from `f6/b0` into the
|
||||||
|
encrypted `f6/o`, then `clear()` + `deleteSharedPreferences()` the plaintext
|
||||||
|
file. So the token is no longer recoverable in cleartext on current installs.
|
||||||
|
|
||||||
## API essentials
|
## API essentials
|
||||||
|
|
||||||
Full detail in `api.md`. Quick reference:
|
Full detail in `api.md`. Quick reference:
|
||||||
|
|
||||||
- Responses are wrapped `{ "data": ..., "errors": ... }`; `errors` is `null` on success.
|
- Responses are wrapped `{ "data": ..., "errors": ... }`; `errors` is `null` on success.
|
||||||
- **Auth:** `POST /v1/Authorize/LogInWithEmail` (white-label ID goes in the body)
|
- **Auth:** `POST /v1/Authorize/LogInWithEmail` (white-label ID goes in the body)
|
||||||
→ reuse the returned `bearer <token>` as the `Authorization` header. Token
|
→ reuse the returned `bearer <token>` as the `Authorization` header. Treat the
|
||||||
expiry is unconfirmed (`expireTime` was `null`).
|
token as long-lived: there's no refresh token, the app persists only the token
|
||||||
|
(not the email/password) and discards the response's `expireTime`, so refetch
|
||||||
|
reactively on `401`/`403`. See `api.md` and the token-storage notes above.
|
||||||
- Authenticated endpoints need **only** the `Authorization` header — the `X-Go-*`
|
- Authenticated endpoints need **only** the `Authorization` header — the `X-Go-*`
|
||||||
headers and app `User-Agent` the app sends are not required (verified against
|
headers and app `User-Agent` the app sends are not required (verified against
|
||||||
the clubs endpoint).
|
the clubs endpoint).
|
||||||
@@ -107,6 +149,16 @@ Full detail in `api.md`. Quick reference:
|
|||||||
`manifest.json` and the flake on code changes. The integration has **no**
|
`manifest.json` and the flake on code changes. The integration has **no**
|
||||||
external `requirements`, so no extra Nix packaging is needed.
|
external `requirements`, so no extra Nix packaging is needed.
|
||||||
|
|
||||||
|
## Checks
|
||||||
|
|
||||||
|
There is no test suite. Verify changes with:
|
||||||
|
|
||||||
|
- `direnv exec . python -m py_compile custom_components/west_wood_club/*.py` — fast
|
||||||
|
syntax check of the component.
|
||||||
|
- `nix build .#west_wood_club` — builds the component and runs nixpkgs' manifest
|
||||||
|
and import checks (the closest thing to CI here). Remember to `git add` new files
|
||||||
|
first, or the flake won't see them.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
The capture and `token.txt` hold live credentials/tokens. Keep them gitignored,
|
The capture and `token.txt` hold live credentials/tokens. Keep them gitignored,
|
||||||
|
|||||||
@@ -228,17 +228,19 @@ overrides and was empty in the capture.
|
|||||||
|
|
||||||
`GET /v1/PersonalTrainings/Bookings?timestamp=0`
|
`GET /v1/PersonalTrainings/Bookings?timestamp=0`
|
||||||
|
|
||||||
The account's PT sessions. `Classes/BookingsV2` (same shape, class bookings) was
|
The account's own PT sessions — **past and future in one list**, newest-booked
|
||||||
empty in the capture. `instructorId` maps to `Instructors/Instructors`;
|
last. The endpoint does not filter by date; to surface *upcoming* bookings,
|
||||||
`clubId` to the club list.
|
filter client-side on `startDate > now` (and typically `not isCanceled` /
|
||||||
|
`not isCompleted`). `Classes/BookingsV2` (same shape, for class bookings) was
|
||||||
|
empty in the capture.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "1st Consultation",
|
"name": "New - 4th Program- Review",
|
||||||
"startDate": "2026-04-20T08:45:00+01:00",
|
"startDate": "2026-05-26T08:30:00+01:00",
|
||||||
"endDate": "2026-04-20T09:15:00+01:00",
|
"endDate": "2026-05-26T09:00:00+01:00",
|
||||||
"isCanceled": false,
|
"isCanceled": false,
|
||||||
"isCompleted": false,
|
"isCompleted": true,
|
||||||
"instructorId": 0,
|
"instructorId": 0,
|
||||||
"clubId": 962,
|
"clubId": 962,
|
||||||
"personalTrainingTypeId": 0,
|
"personalTrainingTypeId": 0,
|
||||||
@@ -248,6 +250,23 @@ empty in the capture. `instructorId` maps to `Instructors/Instructors`;
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
|
||||||
|
- `name` — already human-readable and self-contained (e.g.
|
||||||
|
`"New - 1st Consultation"`, `"New - 4th Program- Review"`). A "next PT booking"
|
||||||
|
sensor needs **only this endpoint** — the lookups below are enrichment.
|
||||||
|
- `startDate` / `endDate` — ISO-8601 **with** offset (`+01:00`); the duration is
|
||||||
|
implied (no separate field on the booking).
|
||||||
|
- `isCanceled` / `isCompleted` — booleans. A future session has both `false`.
|
||||||
|
- `instructorId` → `Instructors/Instructors`; `personalTrainingTypeId` →
|
||||||
|
`PersonalTrainings/PersonalTrainingsTypes`; `clubId` → the club list. Note the
|
||||||
|
booking's own `name` does **not** match the type's `name`.
|
||||||
|
|
||||||
|
**Delta sync:** like other catalogue endpoints, passing the largest `timestamp`
|
||||||
|
seen in a previous response (instead of `0`) returns only rows changed since —
|
||||||
|
`data: []` when nothing changed. A simple poller can ignore this and always send
|
||||||
|
`timestamp=0` to get the full list each time.
|
||||||
|
|
||||||
### Membership contract
|
### Membership contract
|
||||||
|
|
||||||
`GET /v1/RemoteAccounts/Contracts?timestamp=0`
|
`GET /v1/RemoteAccounts/Contracts?timestamp=0`
|
||||||
@@ -415,7 +434,10 @@ classes. All empty in capture.
|
|||||||
|
|
||||||
### Instructors
|
### Instructors
|
||||||
|
|
||||||
`GET /v1/Instructors/Instructors?timestamp=0` — instructor directory:
|
`GET /v1/Instructors/Instructors?timestamp=0` — instructor directory. Large list
|
||||||
|
(hundreds of rows), mostly `isActive: false` and/or `isDeleted: true` legacy
|
||||||
|
staff; `position` is a department label (`Swim`, `Tennis`, `Sales`, …). Only
|
||||||
|
needed to resolve a booking's `instructorId` to a name.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -428,7 +450,8 @@ classes. All empty in capture.
|
|||||||
"photoUrl": null,
|
"photoUrl": null,
|
||||||
"description": null,
|
"description": null,
|
||||||
"companyId": 251,
|
"companyId": 251,
|
||||||
"id": 0
|
"id": 0,
|
||||||
|
"isDeleted": false
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -437,8 +460,22 @@ classes. All empty in capture.
|
|||||||
capture).
|
capture).
|
||||||
|
|
||||||
`GET /v1/PersonalTrainings/PersonalTrainingsTypes?timestamp=0` — PT session-type
|
`GET /v1/PersonalTrainings/PersonalTrainingsTypes?timestamp=0` — PT session-type
|
||||||
catalogue (`name`, `duration`, `productId`). `personalTrainingTypeId` on a PT
|
catalogue; `personalTrainingTypeId` on a PT booking points here. This is where the
|
||||||
booking points here.
|
session `duration` lives (the booking itself carries only start/end). `name`
|
||||||
|
ranges over paid sessions (`"60 min PT session"`, `"45 min PT session FREE"`),
|
||||||
|
consultations/reviews, and non-session blocks (`"Lunch 30 min"`, `"Shower 15
|
||||||
|
min"`). Many rows are `isDeleted: true` legacy types.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "60 min PT session",
|
||||||
|
"duration": "01:00",
|
||||||
|
"productId": 105674,
|
||||||
|
"companyId": 251,
|
||||||
|
"id": 0,
|
||||||
|
"isDeleted": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Products & pricing
|
### Products & pricing
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user