Skip to content

feat: self-hosted backend URL override in Developer Settings#6604

Open
Rahulsharma0810 wants to merge 3 commits intoBasedHardware:mainfrom
Rahulsharma0810:feat/self-hosted-backend-url
Open

feat: self-hosted backend URL override in Developer Settings#6604
Rahulsharma0810 wants to merge 3 commits intoBasedHardware:mainfrom
Rahulsharma0810:feat/self-hosted-backend-url

Conversation

@Rahulsharma0810
Copy link
Copy Markdown

Problem

Self-hosting the Omi backend is documented and supported, but the iOS app hardcodes api.omi.me — making it impossible to use a self-hosted backend without maintaining a full fork of the app.

This was raised in #842 and closed citing "e2e authentication" as the blocker. That reasoning is incomplete:

  • Firebase mismatch is already solved by LOCAL_DEVELOPMENT=true in the backend (endpoints.py) — self-hosters can enable this flag
  • The real blocker is Flutter's dart:io using BoringSSL (its own TLS stack), which ignores iOS's system certificate store. Even with a custom CA installed and trusted in iOS Settings, the app rejects it with CERTIFICATE_VERIFY_FAILED. The only fix is using a domain you control — which requires changing the hardcoded URL

Solution

Add a Self-Hosted Backend section to Developer Settings with a URL text field. The infrastructure was already 90% in place:

// env.dart — already existed, just never exposed in UI
static String? _apiBaseUrlOverride;
static void overrideApiBaseUrl(String url) { ... }
static String? get apiBaseUrl => _apiBaseUrlOverride ?? _instance.apiBaseUrl;

This PR simply wires a UI to that existing method.

Changes

  • app/lib/backend/preferences.dart — add customBackendUrl getter/setter (1 key in SharedPreferences)
  • app/lib/main.dart — restore saved URL via Env.overrideApiBaseUrl() on launch, before any network calls
  • app/lib/pages/settings/developer.dart — new "Self-Hosted Backend" section with text field, save, and clear

Behaviour

  1. User enters https://my-backend.example.com in Developer Settings → Self-Hosted Backend
  2. Taps Save Backend URL — persisted to SharedPreferences, snackbar prompts restart
  3. On next launch: main.dart restores the URL before any network call
  4. All API and WebSocket calls go to the custom backend
  5. Clear field + save → reverts to default api.omi.me

A note in the UI tells users their backend needs LOCAL_DEVELOPMENT=true for Firebase token bypass.

Who needs this

What this is NOT

This does not remove or weaken any existing auth. It only lets users redirect API calls to a backend they control. The backend is still responsible for its own auth decisions.

Closes #842

Adds a 'Self-Hosted Backend' section to Developer Settings that lets
users point the app at their own backend without rebuilding from source.

Changes:
- SharedPreferencesUtil: add customBackendUrl getter/setter
- main.dart: restore saved URL via Env.overrideApiBaseUrl() on launch
- developer.dart: UI with text field, save/clear, and restart reminder

The infrastructure was already in place (Env.overrideApiBaseUrl() and
_apiBaseUrlOverride in env.dart) — this simply exposes it in the UI.

Backend requirement: set LOCAL_DEVELOPMENT=true to bypass Firebase
token verification when the app's tokens are for a different Firebase
project than the self-hosted backend.

Closes BasedHardware#842

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 13, 2026

Greptile Summary

This PR adds a self-hosted backend URL override to Developer Settings, wiring a new customBackendUrl SharedPreferences key into an existing Env.overrideApiBaseUrl() hook. The core plumbing is sound, but two runtime defects need addressing before merge.

  • TestFlight staging override silently wins: In main.dart the custom backend block runs first, then the TestFlight staging detection block runs unconditionally after — meaning any TestFlight user with testFlightUseStagingApi=true will have their custom URL silently replaced by the staging URL, making the feature non-functional for that combination.
  • Clear button breaks live API calls: The "✕" clear icon calls Env.overrideApiBaseUrl(''), setting _apiBaseUrlOverride to an empty string. Because Env.apiBaseUrl uses ?? (not a null/empty check), the override takes effect immediately and all API requests in the current session will construct against '' until a restart — the opposite of the stated "restart required" behavior.

Confidence Score: 4/5

Not safe to merge as-is — two P1 defects can break API calls or silently ignore the user's custom URL.

Two P1 issues remain: TestFlight staging always overwrites a custom backend URL, and the clear button immediately corrupts the live Env state with an empty-string override. Both are straightforward to fix but affect core functionality of the feature being added.

app/lib/main.dart (TestFlight ordering) and app/lib/pages/settings/developer.dart (clear button Env call)

Important Files Changed

Filename Overview
app/lib/main.dart Adds custom backend URL restoration at startup, but the ordering lets TestFlight staging always override the custom URL when both are configured.
app/lib/pages/settings/developer.dart New Self-Hosted Backend UI section with save/clear logic; clear button incorrectly calls Env.overrideApiBaseUrl('') which breaks live sessions; all new user-facing strings are hardcoded (l10n missing).
app/lib/backend/preferences.dart Adds customBackendUrl getter/setter correctly, but placed in the Auth section instead of Developer Settings section.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[App Launch: _init] --> B[SharedPreferencesUtil.init]
    B --> C{customBackendUrl not empty?}
    C -- Yes --> D[Env.overrideApiBaseUrl customBackend]
    C -- No --> E{F.env == prod?}
    D --> E
    E -- Yes --> F{isTestFlight?}
    E -- No --> Z[App Ready]
    F -- No --> Z
    F -- Yes --> G[Env.isTestFlight = true]
    G --> H{testFlightUseStagingApi?}
    H -- No --> Z
    H -- Yes --> I[Env.overrideApiBaseUrl stagingUrl]
    I --> Z
    style I fill:#c0392b,color:#fff
    style D fill:#27ae60,color:#fff
Loading

Comments Outside Diff (1)

  1. app/lib/main.dart, line 154-178 (link)

    P1 TestFlight staging silently overrides custom backend URL

    The custom backend block runs first (lines 155–159), then the TestFlight staging block runs unconditionally after it (lines 162–178). For a TestFlight user who has testFlightUseStagingApi=true and has also set a custom backend URL, the staging URL always wins and the user-configured URL is silently discarded. The feature the PR adds is completely non-functional for that combination.

    The fix is to skip the TestFlight staging override when a custom backend URL has already been applied:

      // Self-hosted backend URL — must be after SharedPreferencesUtil.init()
      final customBackend = SharedPreferencesUtil().customBackendUrl;
      if (customBackend.isNotEmpty) {
        Env.overrideApiBaseUrl(customBackend);
        debugPrint('Self-hosted backend: using $customBackend');
      }
    
      // TestFlight environment detection — must be after SharedPreferencesUtil.init()
      // Skip TestFlight staging override if a custom backend has been set.
      if (F.env == Environment.prod && customBackend.isEmpty) {
        final isTestFlight = await EnvironmentDetector.isTestFlight();
        if (isTestFlight) {
          Env.isTestFlight = true;
          if (SharedPreferencesUtil().testFlightUseStagingApi) {
            ...
          }
        }
      }

Reviews (1): Last reviewed commit: "feat: add self-hosted backend URL overri..." | Re-trigger Greptile

Comment on lines +677 to +683
onPressed: () {
_backendUrlController.clear();
SharedPreferencesUtil().customBackendUrl = '';
Env.overrideApiBaseUrl('');
setState(() {});
AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply');
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Clear button sets URL to empty string, breaking live API calls

Env.overrideApiBaseUrl('') sets _apiBaseUrlOverride = ''. Because Env.apiBaseUrl uses _apiBaseUrlOverride ?? _instance.apiBaseUrl, an empty string is non-null and passes the ?? check, so all API calls from this point forward will construct requests against '' and fail immediately — before the required restart.

The snackbar already communicates that a restart is needed, so the live Env.overrideApiBaseUrl call on clear is not just redundant but actively harmful. Remove it:

Suggested change
onPressed: () {
_backendUrlController.clear();
SharedPreferencesUtil().customBackendUrl = '';
Env.overrideApiBaseUrl('');
setState(() {});
AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply');
},
onPressed: () {
_backendUrlController.clear();
SharedPreferencesUtil().customBackendUrl = '';
setState(() {});
AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply');
},

Comment on lines +615 to +725
// Self-Hosted Backend Section
_buildSectionHeader(
'Self-Hosted Backend',
subtitle: 'Override the API URL to use your own backend instead of api.omi.me. '
'Leave empty to use the default. Requires app restart to take effect.',
),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFF1C1C1E), borderRadius: BorderRadius.circular(14)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF2A2A2E),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: FaIcon(FontAwesomeIcons.server, color: Colors.grey.shade400, size: 16),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Backend URL',
style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 2),
Text(
Env.apiBaseUrl ?? 'https://api.omi.me',
style: TextStyle(color: Colors.grey.shade500, fontSize: 12),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _backendUrlController,
style: const TextStyle(color: Colors.white, fontSize: 14),
decoration: InputDecoration(
hintText: 'https://your-backend.example.com',
hintStyle: TextStyle(color: Colors.grey.shade600, fontSize: 14),
filled: true,
fillColor: const Color(0xFF2A2A2E),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
suffixIcon: _backendUrlController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, color: Colors.grey.shade500, size: 18),
onPressed: () {
_backendUrlController.clear();
SharedPreferencesUtil().customBackendUrl = '';
Env.overrideApiBaseUrl('');
setState(() {});
AppSnackbar.showSnackbar('Backend URL cleared — restart app to apply');
},
)
: null,
),
onChanged: (_) => setState(() {}),
keyboardType: TextInputType.url,
autocorrect: false,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
final url = _backendUrlController.text.trim().replaceAll(RegExp(r'/+$'), '');
if (url.isNotEmpty && !url.startsWith('http')) {
AppSnackbar.showSnackbar('URL must start with http:// or https://');
return;
}
SharedPreferencesUtil().customBackendUrl = url;
Env.overrideApiBaseUrl(url.isNotEmpty ? url : Env.apiBaseUrl ?? '');
setState(() {});
AppSnackbar.showSnackbar(
url.isEmpty ? 'Restored default backend — restart app to apply' : 'Backend URL saved — restart app to apply',
);
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF2A2A2E),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
elevation: 0,
),
child: const Text('Save Backend URL', style: TextStyle(fontWeight: FontWeight.w500)),
),
),
const SizedBox(height: 10),
Text(
'Note: your backend must have LOCAL_DEVELOPMENT=true to accept tokens from the Omi app.',
style: TextStyle(color: Colors.grey.shade600, fontSize: 11),
),
],
),
),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Hardcoded user-facing strings must use l10n

All user-visible strings in this section are hardcoded English. The codebase rule requires context.l10n.keyName for every user-facing string — there are at least 8 violations in this block:

  • 'Self-Hosted Backend' (section header)
  • 'Backend URL' (row title)
  • 'https://your-backend.example.com' (hint text)
  • 'Save Backend URL' (button label)
  • 'Note: your backend must have LOCAL_DEVELOPMENT=true…'
  • 'Backend URL cleared — restart app to apply'
  • 'URL must start with http:// or https://'
  • 'Restored default backend — restart app to apply' / 'Backend URL saved — restart app to apply'

Add the corresponding keys to app/lib/l10n/app_en.arb and provide translations for all 33 non-English locales per the project's localization policy.

Context Used: Flutter localization - all user-facing strings mus... (source)

Comment on lines +577 to +579
String get customBackendUrl => getString('customBackendUrl');

set customBackendUrl(String value) => saveString('customBackendUrl', value);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 customBackendUrl placed in the Auth section

This getter/setter was added inside the //------------------------ Auth ------------------------------------// block, but it belongs with the other developer settings under //---------------------- Developer Settings ---------------------------------// (around line 116). Moving it keeps the file's organization coherent and avoids confusion for future readers who look for dev-only preferences in the Auth block.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

- main.dart: skip TestFlight staging override when custom backend is
  already set (user intent takes priority over TestFlight defaults)
- developer.dart: remove Env.overrideApiBaseUrl('') from clear/save
  handlers — empty string is not null so the ?? fallback never fires,
  breaking live API calls; changes now only persist to SharedPreferences
  and take effect on restart as documented in the UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- preferences.dart: move customBackendUrl getter/setter from Auth
  section to Developer Settings section where it belongs
- developer.dart: replace all hardcoded strings with context.l10n keys
- app_en.arb: add 9 new l10n keys for the self-hosted backend UI
  (selfHostedBackendSectionTitle, selfHostedBackendSubtitle,
  selfHostedBackendUrlHint, selfHostedBackendSaveButton,
  selfHostedBackendSavedRestart, selfHostedBackendClearedRestart,
  selfHostedBackendRestoredRestart, selfHostedBackendHttpError,
  selfHostedBackendNote); reuses existing backendUrlLabel key

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@Rahulsharma0810
Copy link
Copy Markdown
Author

All Greptile-flagged issues have been addressed across commits 2 and 3. For clarity:

P1 — Env.overrideApiBaseUrl('') on clear/save (commit 27e583): Removed both calls. Changes now only write to SharedPreferencesUtil and take effect on restart as stated in the UI. No live session state is mutated.

P1 — TestFlight staging overrides custom URL (commit 27e583): Fixed by guarding the TestFlight block with && customBackend.isEmpty so user-configured backend always takes priority.

P2 — Hardcoded strings (commit 451de1): All 9 user-facing strings now use context.l10n keys. Corresponding entries added to app_en.arb.

P2 — customBackendUrl in Auth section (commit 451de1): Moved to the Developer Settings section in preferences.dart.

Greptile's inline comments appear to be cached from earlier commits — the flagged code no longer exists in the current HEAD.

Rahulsharma0810 added a commit to Rahulsharma0810/omi that referenced this pull request Apr 14, 2026
Self-hosted deployments previously had no proper authentication path.
Firebase token verification requires knowing the project ID, but the
backend required a full service account (SERVICE_ACCOUNT_JSON) to
initialize Firebase Admin — credentials only Omi possesses.

Firebase token verification is asymmetric: the Admin SDK verifies JWTs
using Firebase's public signing keys
(https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com)
and only needs the project ID, not any private credentials.

This change adds a FIREBASE_PROJECT_ID initialization path so
self-hosters can verify tokens from any Firebase project (including
Omi's own, whose project ID `based-hardware-dev` is publicly committed
in the repository) without needing a service account.

Combined with the custom backend URL feature (PR BasedHardware#6604), this gives
self-hosters a complete, proper authentication path with zero app
rebuilding required.

Also fixes LOCAL_DEVELOPMENT bypass missing from get_current_user_id()
in dependencies.py — endpoints.py had it but this function did not,
causing auth failures in some code paths during local development.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An ability to change link and add a self-hosted backend inside of an app

1 participant