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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Mobile login/register: when the server field has no scheme or port, probe `GET /api/v1/status` over HTTPS then HTTP, update the field with the resolved URL (TLS/certificate errors still select HTTPS; if both fail, default to HTTPS as before)
- Android release allows cleartext HTTP so the client can reach local/LAN instances without TLS; iOS `Info.plist` sets `NSAllowsLocalNetworking` for the same local-HTTP case

### Changed

- Server URL field hint and validation copy no longer require typing `https://`

### Fixed

- Comment/like lists no longer crash when showing users without an avatar (`UserAvatar` only sets `onBackgroundImageError` when a network image is present)
Expand Down
2 changes: 2 additions & 0 deletions docs/admin/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ cd cmd/grom

Open the **web UI** in a browser at the server’s base URL (same Flutter client as Android — for example `http://localhost:8080/` with `config.dev.notls.yaml`). Register a user, then sign in. See [User overview](../user/overview.md).

The **Android** app (and later iOS) can connect to that same instance: enter the host on the login screen (scheme optional). Cleartext **HTTP is allowed for local/LAN** installs without TLS; use HTTPS when exposing the server beyond the local network. See [User overview](../user/overview.md) for how the client resolves `http` vs `https`.

**API docs (Swagger UI):** `http://<host>:<port>/api/docs/` (for example `http://localhost:8080/api/docs/`). Generated OpenAPI sources also live under `api/docs/` in the repository.

CLI help:
Expand Down
2 changes: 2 additions & 0 deletions docs/user/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

Grom’s Flutter client runs as a **web UI** and as an **Android** app. The web UI is served by the same `grom` process: open the server’s base URL in a browser (for example `http://localhost:8080/` with the default dev config). The screens and flows match the Android app; live GPS recording is Android-only. UI strings are available in English, Russian, and German.

On **Android** (and later iOS), sign-in and registration ask for a **server URL**. You can enter a bare host such as `grom.example.com` (no `https://` required). On submit the app probes `GET /api/v1/status` over HTTPS, then HTTP, writes the resolved URL into the field, and continues. If you already type `http://` / `https://` or an explicit port, that value is used as-is. **HTTP is supported for local / LAN instances** without TLS; prefer HTTPS for anything reachable on the public internet.

This page is a short tour of the main screens (screenshots below are from Android). Admin setup (install, config, TLS, federation) lives under [Admin docs](../README.md#admin). For the HTTP API, see Swagger at `/api/docs/` on a running server.

## Workouts
Expand Down
3 changes: 2 additions & 1 deletion ui/grom/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:networkSecurityConfig="@xml/network_security_config">
<service
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
android:foregroundServiceType="location"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Cleartext HTTP is allowed so the mobile client can reach local / LAN
Grom instances without TLS. Prefer HTTPS when available. -->
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
5 changes: 5 additions & 0 deletions ui/grom/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Grom needs your location to record workout GPS tracks and show your position on the map.</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
Expand Down
3 changes: 2 additions & 1 deletion ui/grom/lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"passwordsDoNotMatch": "Passwörter stimmen nicht überein",
"serverUrlLabel": "Server-URL *",
"enterServerUrl": "Server-URL eingeben",
"enterValidServerUrl": "Gültige URL eingeben (https://...)",
"enterValidServerUrl": "Gültigen Server-Host oder URL eingeben",
"serverUrlHint": "example.com",
"language": "Sprache",
"languageEnglish": "English",
"languageRussian": "Русский",
Expand Down
3 changes: 2 additions & 1 deletion ui/grom/lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"passwordsDoNotMatch": "Passwords do not match",
"serverUrlLabel": "Server URL *",
"enterServerUrl": "Enter server URL",
"enterValidServerUrl": "Enter a valid URL (https://...)",
"enterValidServerUrl": "Enter a valid server host or URL",
"serverUrlHint": "example.com",
"language": "Language",
"languageEnglish": "English",
"languageRussian": "Russian",
Expand Down
8 changes: 7 additions & 1 deletion ui/grom/lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -277,9 +277,15 @@ abstract class AppLocalizations {
/// No description provided for @enterValidServerUrl.
///
/// In en, this message translates to:
/// **'Enter a valid URL (https://...)'**
/// **'Enter a valid server host or URL'**
String get enterValidServerUrl;

/// No description provided for @serverUrlHint.
///
/// In en, this message translates to:
/// **'example.com'**
String get serverUrlHint;

/// No description provided for @language.
///
/// In en, this message translates to:
Expand Down
5 changes: 4 additions & 1 deletion ui/grom/lib/l10n/app_localizations_de.dart
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get enterServerUrl => 'Server-URL eingeben';

@override
String get enterValidServerUrl => 'Gültige URL eingeben (https://...)';
String get enterValidServerUrl => 'Gültigen Server-Host oder URL eingeben';

@override
String get serverUrlHint => 'example.com';

@override
String get language => 'Sprache';
Expand Down
5 changes: 4 additions & 1 deletion ui/grom/lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get enterServerUrl => 'Enter server URL';

@override
String get enterValidServerUrl => 'Enter a valid URL (https://...)';
String get enterValidServerUrl => 'Enter a valid server host or URL';

@override
String get serverUrlHint => 'example.com';

@override
String get language => 'Language';
Expand Down
5 changes: 4 additions & 1 deletion ui/grom/lib/l10n/app_localizations_ru.dart
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ class AppLocalizationsRu extends AppLocalizations {
String get enterServerUrl => 'Введите URL сервера';

@override
String get enterValidServerUrl => 'Введите корректный URL (https://...)';
String get enterValidServerUrl => 'Введите корректный хост или URL сервера';

@override
String get serverUrlHint => 'example.com';

@override
String get language => 'Язык';
Expand Down
3 changes: 2 additions & 1 deletion ui/grom/lib/l10n/app_ru.arb
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"passwordsDoNotMatch": "Пароли не совпадают",
"serverUrlLabel": "URL сервера *",
"enterServerUrl": "Введите URL сервера",
"enterValidServerUrl": "Введите корректный URL (https://...)",
"enterValidServerUrl": "Введите корректный хост или URL сервера",
"serverUrlHint": "example.com",
"language": "Язык",
"languageEnglish": "English",
"languageRussian": "Русский",
Expand Down
7 changes: 6 additions & 1 deletion ui/grom/lib/login.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'api_request.dart';
import 'auth_storage.dart';
import 'platform/is_mobile_client.dart';
import 'server_storage.dart';
import 'server_url_resolver.dart';
import 'widgets/server_url_field.dart';

class LoginForm extends StatefulWidget {
Expand Down Expand Up @@ -64,7 +65,11 @@ class _LoginFormState extends State<LoginForm> {

try {
if (isMobileClient) {
await ServerStorage.saveBaseUrl(_serverUrlController.text);
final resolved = await resolveServerBaseUrl(_serverUrlController.text);
if (mounted) {
_serverUrlController.text = resolved;
}
await ServerStorage.saveBaseUrl(resolved);
}

final result = await _api.login(
Expand Down
3 changes: 3 additions & 0 deletions ui/grom/lib/platform/server_scheme_probe.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export 'server_scheme_probe_result.dart';
export 'server_scheme_probe_stub.dart'
if (dart.library.io) 'server_scheme_probe_io.dart';
49 changes: 49 additions & 0 deletions ui/grom/lib/platform/server_scheme_probe_io.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'dart:async';
import 'dart:io';

import 'server_scheme_probe_result.dart';

const _probeTimeout = Duration(seconds: 3);

/// Probes `GET statusUri` with a short timeout; follows redirects.
Future<SchemeProbeResult> defaultSchemeProbe(Uri statusUri) async {
final client = HttpClient();
client.connectionTimeout = _probeTimeout;
client.idleTimeout = _probeTimeout;

try {
final request = await client.getUrl(statusUri).timeout(_probeTimeout);
request.followRedirects = true;
request.maxRedirects = 5;

final response = await request.close().timeout(_probeTimeout);

var effective = statusUri;
for (final redirect in response.redirects) {
effective = effective.resolveUri(redirect.location);
}

await response.drain<void>().timeout(_probeTimeout);

return SchemeProbeResult(
reached: true,
finalUri: effective,
);
} on HandshakeException {
return const SchemeProbeResult(reached: false, tlsPresent: true);
} on CertificateException {
return const SchemeProbeResult(reached: false, tlsPresent: true);
} on TlsException {
return const SchemeProbeResult(reached: false, tlsPresent: true);
} on TimeoutException {
return const SchemeProbeResult(reached: false);
} on SocketException {
return const SchemeProbeResult(reached: false);
} on HttpException {
return const SchemeProbeResult(reached: false);
} catch (_) {
return const SchemeProbeResult(reached: false);
} finally {
client.close(force: true);
}
}
17 changes: 17 additions & 0 deletions ui/grom/lib/platform/server_scheme_probe_result.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/// Outcome of probing `GET {base}/api/v1/status`.
class SchemeProbeResult {
const SchemeProbeResult({
required this.reached,
this.tlsPresent = false,
this.finalUri,
});

/// Any HTTP response was received (including non-2xx).
final bool reached;

/// TLS/certificate failed but the endpoint appears to speak TLS — treat as HTTPS.
final bool tlsPresent;

/// Final URI after redirects when [reached] is true.
final Uri? finalUri;
}
8 changes: 8 additions & 0 deletions ui/grom/lib/platform/server_scheme_probe_stub.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import 'server_scheme_probe_result.dart';

export 'server_scheme_probe_result.dart';

/// Web / non-IO stub — probing is mobile-only.
Future<SchemeProbeResult> defaultSchemeProbe(Uri statusUri) async {
return const SchemeProbeResult(reached: false);
}
7 changes: 6 additions & 1 deletion ui/grom/lib/registration.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'api_request.dart';
import 'login.dart';
import 'platform/is_mobile_client.dart';
import 'server_storage.dart';
import 'server_url_resolver.dart';
import 'widgets/server_url_field.dart';

class RegistrationForm extends StatefulWidget {
Expand Down Expand Up @@ -71,7 +72,11 @@ class _RegistrationFormState extends State<RegistrationForm> {

try {
if (isMobileClient) {
await ServerStorage.saveBaseUrl(_serverUrlController.text);
final resolved = await resolveServerBaseUrl(_serverUrlController.text);
if (mounted) {
_serverUrlController.text = resolved;
}
await ServerStorage.saveBaseUrl(resolved);
}

await _api.register(
Expand Down
101 changes: 101 additions & 0 deletions ui/grom/lib/server_url_resolver.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import 'server_storage.dart';
import 'platform/server_scheme_probe.dart';

export 'platform/server_scheme_probe_result.dart';

typedef SchemeProbe = Future<SchemeProbeResult> Function(Uri statusUri);

const _statusPathSuffix = '/api/v1/status';

/// Whether scheme auto-detection should run for [input].
///
/// Skips when the user already provided `http(s)://` or an explicit port.
bool needsSchemeProbe(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) {
return false;
}

final lower = trimmed.toLowerCase();
if (lower.startsWith('http://') || lower.startsWith('https://')) {
return false;
}
if (trimmed.contains('://')) {
return false;
}

final provisional = Uri.tryParse('https://$trimmed');
if (provisional == null || provisional.host.isEmpty) {
return false;
}
if (provisional.hasPort) {
return false;
}

return true;
}

Uri statusUriForBase(String baseUrl) {
final normalized = ServerStorage.normalizeBaseUrl(baseUrl);
return Uri.parse('$normalized$_statusPathSuffix');
}

/// Derives a server base URL from a final `/api/v1/status` URI (after redirects).
String baseUrlFromStatusUri(Uri statusUri) {
var path = statusUri.path;
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
if (path.endsWith(_statusPathSuffix)) {
path = path.substring(0, path.length - _statusPathSuffix.length);
}

final buffer = StringBuffer('${statusUri.scheme}://${statusUri.host}');
if (statusUri.hasPort) {
buffer.write(':${statusUri.port}');
}
if (path.isNotEmpty && path != '/') {
buffer.write(path);
}

return ServerStorage.normalizeBaseUrl(buffer.toString());
}

/// Resolves a user-entered server locator to a normalized base URL.
///
/// When [needsSchemeProbe] is true, tries HTTPS then HTTP against `/api/v1/status`.
/// TLS/certificate errors count as HTTPS. If neither responds, falls back to HTTPS.
Future<String> resolveServerBaseUrl(
String input, {
SchemeProbe? probe,
}) async {
final trimmed = input.trim();
if (trimmed.isEmpty) {
return trimmed;
}

if (!needsSchemeProbe(trimmed)) {
return ServerStorage.normalizeBaseUrl(trimmed);
}

final probeFn = probe ?? defaultSchemeProbe;
final httpsBase = ServerStorage.normalizeBaseUrl('https://$trimmed');
final httpsResult = await probeFn(statusUriForBase(httpsBase));
if (httpsResult.reached || httpsResult.tlsPresent) {
if (httpsResult.reached && httpsResult.finalUri != null) {
return baseUrlFromStatusUri(httpsResult.finalUri!);
}
return httpsBase;
}

final httpBase = ServerStorage.normalizeBaseUrl('http://$trimmed');
final httpResult = await probeFn(statusUriForBase(httpBase));
if (httpResult.reached) {
if (httpResult.finalUri != null) {
return baseUrlFromStatusUri(httpResult.finalUri!);
}
return httpBase;
}

return httpsBase;
}
2 changes: 1 addition & 1 deletion ui/grom/lib/widgets/server_url_field.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ class ServerUrlField extends StatelessWidget {
decoration: InputDecoration(
labelText: l10n.serverUrlLabel,
border: const OutlineInputBorder(),
hintText: 'https://example.com',
hintText: l10n.serverUrlHint,
),
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
Expand Down
Loading