Skip to content

Otera Measure (the screening tools as their own app)

Otera Measure is the clinical assessment of the doctor app, sold on its own to clinicians: the camera tests with the live skeleton and the measured report, the phone-on-the-body passive and active tests, the free goniometers of Quick measure, playback with the overlay, and the session reports as PDF. Nothing else of the platform is in it: no patients, plans, messages or visits. It is a separate app in the stores (Otera Measure, bundle com.fedyam.oterameasure), built from the same code as the doctor and patient apps, in English and Russian. Anyone can measure without an account; an account is needed to save what was measured, and it can be created with Google, with Apple, or with an email and a password. It is free for now: a trial and a store subscription are built and switched off (see Free for now, and the subscription).

What a clinician sees

  1. Welcome, once. The mark, one sentence, three things the app does, Start measuring and Sign in, "Free for clinicians. Sign in to save what you measure.", with the EN / RU switch at the top. Start measuring opens the home without an account; the welcome is not shown again on this phone.
  2. Measuring without an account. The home says "Measuring as a guest · Sign in to save your measurements" where a clinician sees the trial line. Phone on the body and Camera: tap a joint open as they do for a clinician: the phone zeroes, the angle and the peak read live, the camera tracks the body and reads the tapped joint, a clip can be recorded and reviewed. Tests by region and Custom test are measured on the server and kept in the account, so for a guest they open a sheet that says so, with Sign in, Create an account and Not now. Where a clinician sees the latest sittings, a guest sees Saved: "Nothing saved yet. Sign in to keep your measurements and session reports here." with Sign in. Settings offer the two ways in instead of the account, and keep motion tracking, the language and the legal pages. A screen that needs the account (a session, a result, the test catalog, reached by a link or a push) asks for it the same way.
  3. Saving as a guest. On the phone goniometer, once the measurements are named, the button reads Save to your account; on the camera goniometer, naming the test asks the same. The sheet offers Sign in and Create an account; Not now leaves the measurements on the screen. After the sign-in (and the consent, for a new account) the home saves what was waiting ("Saving 3 measurements…", "Uploading your recording…") and opens the session, the result or the processing screen, exactly where a clinician would have landed. If saving fails the home says so with Try again and Discard; a recording that is no longer on the phone is dropped. A measurement nobody came back for within an hour is forgotten.
  4. Create your account. Continue with Apple (iOS) and Continue with Google in one tap, or first name, last name, email and password; continuing agrees to the Terms of Service and the Privacy Policy (linked). The consent to data processing follows on its own screen, as in the doctor app, written for a clinician: the data is the account, the measurements and recordings, and app usage; questions about it go to team@oterahealth.com. Every tool is open at once (unless the tenant has been given the doctorApprovalRequired flag, see Feature flags: then the new account sees Your account is waiting for approval, with Check again and Sign out, until an administrator approves it, and an email says when that happens). A Google or Apple account whose verified email already has an account here simply signs in (any address: the provider checked the mailbox when the account was made, as a reset link would).
  5. Sign in. Sign in with Apple (iOS), Sign in with Google, or email and password on one screen (the app has one clinic, so there is no clinic step), Forgot password? with the reset sheet, and Create an account for someone new. A Google or Apple account without an account here is told so and offered Create my account with Google (or Apple's Sign up with Apple) on the spot, with the Terms of Service and the Privacy Policy linked under it: the account is made from the sign-in just done, so there is no second trip through Google, and the name Apple gives only the first time is kept. Nothing is created until that button is tapped. A patient's account is refused with a sentence saying the app is for clinicians.
  6. Home. Under Measure, the ways to start: Tests by region (the catalog, neck to ankles, camera and passive tests in one sitting), Phone on the body, Camera: tap a joint, and Custom test; three numbers once something was measured (tests this week, sessions, tests); and the latest five sittings with Analysing or Needs you where it applies, each opening the session or the test. See all opens every test (All tests). Before the first measurement the home says so and points at the tools above.
  7. The tests. Exactly the doctor app's screens (see clinical-assessment.md): the region checklist, the setup, the camera with the live overlay and the countdown, the passive screen, Quick measure, processing, results with playback, the session with its report and PDF. The only difference: there is no patient to assign a test to, so that chip is absent. When a session's report is ready the clinician gets a push; Otera Measure sends no emails about tests.
  8. Settings (the gear on the home): the account, Motion tracking (the engine, and Try it on camera, which opens the camera goniometer), the language, the legal pages, Sign out, and Delete account. Deleting asks for the name to be typed and says that the clinician is signed out everywhere and their name and email are removed; a Google or Apple account that signed in here is unlinked too. Signing out lands on the home without an account, not on the welcome.

Free for now, and the subscription

Otera Measure sells nothing for now. One build variable decides it, EXPO_PUBLIC_MEASURE_SUBSCRIPTIONS (measureSubscriptionsOn() in lib/appVariant.ts), off unless it is on:

  • Off (now): every clinician is free: every tool opens and nothing is counted. There is no trial line, no paywall (its route goes home), no subscription in settings, and no store note in the delete dialog. The store SDK is neither linked into the build nor in its JavaScript.
  • On: everything below comes back as built, with the store keys and a new native build (the SDK is native code, so an update alone cannot turn it on).

With the subscription on:

  • The trial runs 14 days from the day the account was created (the server's date, so reinstalling does not restart it). While it runs, and while a subscription is current, every tool opens. When it has ended and the store says nothing current was bought, starting a measurement opens the paywall instead, and every screen that records one (a retake or a deep link included) says that the trial has ended and offers the plans; what was measured stays readable: results, sessions, reports and the lists.
  • Not knowing never locks a clinician out. While the account has not loaded, and after the trial while the store has not answered (no store key in this build, RevenueCat unreachable, a new phone offline), the tools stay open. Only the store's own answer closes them.
  • The paywall offers a yearly and a monthly plan from the app's store (App Store or Google Play), the yearly first with its price per month, the store's introductory free trial when the product has one, Restore purchases, and the sentences the stores require about payment, renewal and cancelling (with a store trial: nothing is charged until it ends). Plans that did not load are asked for again when the paywall opens, with Try again. A purchase that fails says so; one waiting for approval (Ask to Buy, a pending Google Play payment) says that instead; cancelling the store's sheet simply keeps the paywall open. A subscribed clinician is told so instead of being sold to again.
  • Two clinicians on one phone: signing out signs the store out too, so the next account never inherits the previous one's purchases.
  • In a build without store keys (development, a preview) the paywall shows the plans as placeholders and says purchases are not available yet; the trial applies, and when it ends the tools stay open (the store cannot answer).
  • The home then shows one line about the trial or the subscription (days left, the last day, that it ended, or that a cancelled subscription ends on a date); settings show the subscription (its state, See plans or Manage subscription, Restore purchases); the welcome offers Start free trial; and the delete dialog adds that a store subscription is not cancelled by deleting the account.

Where things live

Piece Where
The build variant EXPO_PUBLIC_TENANT=OTERA_MEASURE → appType: "measure" in Okta-Mobile/lib/tenantConfig.ts; lib/appVariant.ts (isMeasureApp()); app.config.js (name, bundle id, scheme oterameasure, icon, splash, camera and microphone texts); eas.json profiles development-measure, preview-measure, production-measure; npm run start:measure, update:measure / update:preview-measure
Its own routes app-measure/ is the measure build's expo-router root (app.config.js sets the plugin's root per build): _layout.tsx (fonts, sign-in and entitlement contexts, the push listener), index.tsx, measure-welcome.tsx, measure-sign-in.tsx, measure-sign-up.tsx, permission-consent.tsx (a re-export of the doctor app's), measure/ (index home, paywall, settings), (doctor)/(clinical-assessment)/* (one-line re-exports of the doctor app's assessment screens at the same routes), +not-found.tsx (home); components/measure/. Nothing under app/ is bundled into it: the exported JavaScript holds no patient, plan, messaging or visit screens
Routing app-measure/index.tsx shows the welcome once (lib/measure/guest.ts keeps the flag) and then the home, signed in or not; the consent screen falls back to the home through homeHref(); measure routes are named through measureHref() in lib/appVariant.ts, because typed routes are generated for the doctor app's folder only (typedRoutes is off in the measure build); AuthContext refuses every account but a clinician's
Without an account components/measure/MeasureAccessGuard.tsx: MeasureAccessGuard (the measure routes admit a clinician or nobody) and withMeasureAccountGate (the re-exports under app-measure/(doctor)/(clinical-assessment)/ that read from or write to the server show "Sign in to continue" to a guest; measure and measure-camera are not wrapped); components/measure/SaveToAccountSheet.tsx (the sheet with the two ways in and Not now); the home's guest row, sheet and empty state; the two goniometers (app/(doctor)/(clinical-assessment)/measure.tsx, measure-camera.tsx) branch on isMeasureApp() && isAuthenticated === false
A measurement waiting for an account lib/measure/pendingSave.ts: one stash in AsyncStorage (the named sensor measurements, or the recorded clip's path, step and live metrics), an hour at most; performPendingSave posts it as "Finish & send" would (lib/clinical/sendSensor.ts) or creates the assessment and uploads the clip (lib/clinical/sendClip.ts, shared with the camera goniometer), remembering a created assessment for a retry; the home runs it when it opens with an account and goes to the destination it returns
Sign in with Google and Apple App: lib/auth/socialSignIn.ts (Google through expo-auth-session in the system browser with PKCE, the code exchanged in the app, the browser returning through the app's own id as a URL scheme to the screen that asked; Apple through expo-apple-authentication), components/auth/SocialSignInButtons.tsx (Apple's own button on iOS, Google as a secondary button, nothing when the build has neither), AuthContext.socialLogin, classifySocialSignInError in lib/auth/signInErrors.ts; app.config.js registers the bundle id as a scheme, usesAppleSignIn and the expo-apple-authentication plugin for the measure build. API: POST /v2/user/social-login (src/routes/user/socialLogin.controllers.ts) checks the identity token against the provider's published keys (src/services/socialIdentity.ts: audience, issuer, expiry, a signing key fetched and kept for an hour), then signs in the linked account, links the account with the token's verified email, or creates one when the client asks (createIfMissing, under doctorSelfSignup / measureOnly, with no password); UserIdentity rows (provider, subject, tenant) hold the links. The endpoint answers only on a measureOnly tenant or one with socialSignIn (403 SOCIAL_SIGN_IN_DISABLED elsewhere), lets in only clinicians and patients (staff and clinic admins get 403 PASSWORD_REQUIRED, also when an account was linked before it changed type), and links to the existing account with the token's verified email, whatever the address (since 2026-10-26; before that only where the provider owned the address). The same endpoint now serves the regular apps and the web: Sign in with Google and Apple
Free, the trial and the store measureSubscriptionsOn() in lib/appVariant.ts (the switch; react-native.config.js and lib/measure/purchases.ts read the same variable); lib/measure/entitlement.ts (pure: FREE, the trial from createdAt, the store's answer, the states), lib/measure/purchases.ts (RevenueCat: configured once, logIn / logOut between clinicians, pending payments told apart; loaded only in the measure build and only when a key is set), context/EntitlementContext.tsx (the account, the store, the plans), hooks/useMeasureGate.ts (the gate every start goes through), components/measure/MeasureCaptureGate.tsx (the capture routes of app-measure/, wrapped, for whatever way they are reached)
The home lib/measure/home.ts (the latest sittings); the list asks for the newest 60 tests (GET /v2/clinical-assessment?limit=60) and the three numbers come from GET /v2/clinical-assessment/stats, so the home never downloads every test
The tenant's fence OktaPT-API src/services/measureOnlyTenant.ts (the measureOnly flag, read at most once a minute per tenant) and src/middleware/auth.ts (MEASURE_ONLY_PATHS: a measure session opens the account and /v2/clinical-assessment/* only, 403 MEASURE_ONLY elsewhere); createUser refuses patients there, checkEmailTenants hides the tenant, and the assessment emails are skipped (assessmentPipelineService, clinicalRunReport/service.ts)
Account deletion DELETE /v2/user/me (OktaPT-API src/routes/user/controllers.ts, deleteOwnAccount, clinicians only): the account is deactivated, its name, email, phone, password and second-factor secrets are replaced (the email can sign up again), and its sessions, push tokens and reset links go. Refused while patients are in the clinician's care (HAS_PATIENTS, the doctor app) or while they administer a clinic (CLINIC_ADMINISTRATOR). The measurements and recordings stay, under the closed account, readable by no one
Strings measure.* in Okta-Mobile/locales/{en,ru}.json
Native modules react-native.config.js leaves Zoom's Video SDK (both platforms) and the web view out of the measure build (the picker stays linked on Android: Expo's autolinking keeps a library that ships its own config), and leaves the store SDK (react-native-purchases, with Google Play Billing) out of every other build

Configuration and release

  • The tenant. The app talks to the API with the origin https://measure.oterahealth.com, so a tenant with that domain must exist (name "Otera Measure") with doctorSelfSignup: true and measureOnly: true in its settings (see feature-flags.md); nothing else needs to be on. Accounts created in the app are therapists of that tenant and nothing else. The dev API needs the same row before the app can be tried against it.
  • Password reset. The reset email links to https://measure.oterahealth.com/reset-password, the address the app sends as its origin. That host must serve the web app (a DNS record and the domain added to the US web project on Vercel); the web resolves the tenant from the host, so no code is involved. Keep /reset-password out of the universal-link paths, so the link opens in the browser. An account created with Google or Apple has no password; the reset link sets one when the clinician wants to sign in with one.
  • Sign in with Google. In Google Cloud, a project with the OAuth consent screen filled in (app name, logo, support email, the privacy policy and terms pages, scopes openid, email, profile only) and published, so sign-in is not capped at test users; then two OAuth client ids of the installed-app kinds: iOS (bundle id com.fedyam.oterameasure, the App Store id) and Android (package com.fedyam.oterameasure and the SHA-1 of the signing certificate: the EAS production keystore's, from eas credentials -p android, and the debug keystore's for development builds). The app reads them from EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID and EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID, set per environment like the other public variables: in the measure build profiles of eas.json (builds), inline in the start:measure, update:measure and update:preview-measure scripts (eas update ignores eas.json), and in .env locally. They are public identifiers, not secrets. Without one for its platform the Google button does not show. The API accepts tokens minted for those clients through GOOGLE_SIGN_IN_CLIENT_IDS (comma-separated); without it the endpoint answers 503 PROVIDER_NOT_CONFIGURED. The web app's own client is GOOGLE_SIGN_IN_WEB_CLIENT_ID (Sign in with Google and Apple); Otera Measure itself needs none.
  • Sign in with Apple. The capability Sign in with Apple on the App ID com.fedyam.oterameasure in the developer portal (EAS turns it on from usesAppleSignIn when it syncs capabilities). The API checks the tokens against the bundle id in APPLE_SIGN_IN_AUDIENCES. Apple gives the person's name only on their first sign-in, so the app sends it along with the token then; Apple's private relay addresses are accepted like any verified email. App Review guideline 4.8 asks for Sign in with Apple wherever a third-party sign-in such as Google is offered on iOS, which is why both ship together.
  • Native modules. The sign-in modules (expo-auth-session, expo-crypto, expo-apple-authentication) are native code: the measure app needs a new build (eas build --profile production-measure), not an update, for this release; development builds and simulator builds have to be made again too. The measure app's version is 1.1.0 from this release on: the runtime version follows the app version, so an update with this code never reaches a 1.0.0 build, which lacks the modules and would crash.
  • The store (only to turn the subscription on). Set EXPO_PUBLIC_MEASURE_SUBSCRIPTIONS=on for the measure builds and updates. Subscriptions go through RevenueCat: a project with an entitlement measure_pro attached to the App Store and Google Play subscriptions, an offering whose current packages are $rc_annual and $rc_monthly, and the two public SDK keys as EXPO_PUBLIC_REVENUECAT_IOS_KEY and EXPO_PUBLIC_REVENUECAT_ANDROID_KEY as EAS environment variables (production and preview). An eas update reads those, not a build profile's env; an update without them stops selling (it does not lock anyone out). The app already gives 14 days without a card, so the store products need no introductory free trial of their own.
  • Builds. The measure app is a store build of its own (eas build --profile production-measure), with its own App Store Connect and Play Console records; the profile sets the tenant, so the bundle id, icon, name, routes folder and linked native modules follow. JavaScript-only changes reach it with npm run update:measure.
  • Locally. npm run start:measure starts Metro for the measure build (point EXPO_PUBLIC_API_URL at the API to use). It clears Metro's cache and skips Expo's TypeScript setup: Metro's cache does not know which tenant a file was built for, and the TypeScript setup would rewrite tsconfig.json for a build without typed routes. Clear the cache too when going back to the doctor app.
  • App Review. It is a different product from Otera Health (its own screens, audience and business model, a smaller binary without the platform's code), but it comes from the same developer account: present it as a standalone measurement tool for clinicians in the listing and the review notes, and give the reviewer a test account.
  • What the server does not do yet. With the subscription on, the trial and the subscription are decided in the app from the account's creation date and the store's answer; the API does not refuse a lapsed clinician's tests. A RevenueCat webhook into the billing tables is the next step when the first paying clinicians arrive.