diff --git a/CHANGELOG.md b/CHANGELOG.md index fceae61..a250e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/docs/admin/install.md b/docs/admin/install.md index 803cf60..1ac23b1 100644 --- a/docs/admin/install.md +++ b/docs/admin/install.md @@ -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://:/api/docs/` (for example `http://localhost:8080/api/docs/`). Generated OpenAPI sources also live under `api/docs/` in the repository. CLI help: diff --git a/docs/user/overview.md b/docs/user/overview.md index 950839b..9738ed7 100644 --- a/docs/user/overview.md +++ b/docs/user/overview.md @@ -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 diff --git a/ui/grom/android/app/src/main/AndroidManifest.xml b/ui/grom/android/app/src/main/AndroidManifest.xml index a33bcf8..f0b5b2b 100644 --- a/ui/grom/android/app/src/main/AndroidManifest.xml +++ b/ui/grom/android/app/src/main/AndroidManifest.xml @@ -17,7 +17,8 @@ + android:icon="@mipmap/ic_launcher" + android:networkSecurityConfig="@xml/network_security_config"> + + + + diff --git a/ui/grom/ios/Runner/Info.plist b/ui/grom/ios/Runner/Info.plist index b2f487e..b894ba9 100644 --- a/ui/grom/ios/Runner/Info.plist +++ b/ui/grom/ios/Runner/Info.plist @@ -45,6 +45,11 @@ UIApplicationSupportsIndirectInputEvents + NSAppTransportSecurity + + NSAllowsLocalNetworking + + NSLocationWhenInUseUsageDescription Grom needs your location to record workout GPS tracks and show your position on the map. NSLocationAlwaysAndWhenInUseUsageDescription diff --git a/ui/grom/lib/l10n/app_de.arb b/ui/grom/lib/l10n/app_de.arb index e9d0bb1..07ca36c 100644 --- a/ui/grom/lib/l10n/app_de.arb +++ b/ui/grom/lib/l10n/app_de.arb @@ -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": "Русский", diff --git a/ui/grom/lib/l10n/app_en.arb b/ui/grom/lib/l10n/app_en.arb index 0782b83..08ca349 100644 --- a/ui/grom/lib/l10n/app_en.arb +++ b/ui/grom/lib/l10n/app_en.arb @@ -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", diff --git a/ui/grom/lib/l10n/app_localizations.dart b/ui/grom/lib/l10n/app_localizations.dart index fcb22ec..51d569f 100644 --- a/ui/grom/lib/l10n/app_localizations.dart +++ b/ui/grom/lib/l10n/app_localizations.dart @@ -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: diff --git a/ui/grom/lib/l10n/app_localizations_de.dart b/ui/grom/lib/l10n/app_localizations_de.dart index a5c65b1..4c0f7a2 100644 --- a/ui/grom/lib/l10n/app_localizations_de.dart +++ b/ui/grom/lib/l10n/app_localizations_de.dart @@ -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'; diff --git a/ui/grom/lib/l10n/app_localizations_en.dart b/ui/grom/lib/l10n/app_localizations_en.dart index b42f252..48b2b3b 100644 --- a/ui/grom/lib/l10n/app_localizations_en.dart +++ b/ui/grom/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/ui/grom/lib/l10n/app_localizations_ru.dart b/ui/grom/lib/l10n/app_localizations_ru.dart index 5b24244..d68697b 100644 --- a/ui/grom/lib/l10n/app_localizations_ru.dart +++ b/ui/grom/lib/l10n/app_localizations_ru.dart @@ -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 => 'Язык'; diff --git a/ui/grom/lib/l10n/app_ru.arb b/ui/grom/lib/l10n/app_ru.arb index dcbaebf..be689ee 100644 --- a/ui/grom/lib/l10n/app_ru.arb +++ b/ui/grom/lib/l10n/app_ru.arb @@ -43,7 +43,8 @@ "passwordsDoNotMatch": "Пароли не совпадают", "serverUrlLabel": "URL сервера *", "enterServerUrl": "Введите URL сервера", - "enterValidServerUrl": "Введите корректный URL (https://...)", + "enterValidServerUrl": "Введите корректный хост или URL сервера", + "serverUrlHint": "example.com", "language": "Язык", "languageEnglish": "English", "languageRussian": "Русский", diff --git a/ui/grom/lib/login.dart b/ui/grom/lib/login.dart index b849d1f..da375ba 100644 --- a/ui/grom/lib/login.dart +++ b/ui/grom/lib/login.dart @@ -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 { @@ -64,7 +65,11 @@ class _LoginFormState extends State { 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( diff --git a/ui/grom/lib/platform/server_scheme_probe.dart b/ui/grom/lib/platform/server_scheme_probe.dart new file mode 100644 index 0000000..313da7f --- /dev/null +++ b/ui/grom/lib/platform/server_scheme_probe.dart @@ -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'; diff --git a/ui/grom/lib/platform/server_scheme_probe_io.dart b/ui/grom/lib/platform/server_scheme_probe_io.dart new file mode 100644 index 0000000..a9f84ee --- /dev/null +++ b/ui/grom/lib/platform/server_scheme_probe_io.dart @@ -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 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().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); + } +} diff --git a/ui/grom/lib/platform/server_scheme_probe_result.dart b/ui/grom/lib/platform/server_scheme_probe_result.dart new file mode 100644 index 0000000..975c2fe --- /dev/null +++ b/ui/grom/lib/platform/server_scheme_probe_result.dart @@ -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; +} diff --git a/ui/grom/lib/platform/server_scheme_probe_stub.dart b/ui/grom/lib/platform/server_scheme_probe_stub.dart new file mode 100644 index 0000000..c614da8 --- /dev/null +++ b/ui/grom/lib/platform/server_scheme_probe_stub.dart @@ -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 defaultSchemeProbe(Uri statusUri) async { + return const SchemeProbeResult(reached: false); +} diff --git a/ui/grom/lib/registration.dart b/ui/grom/lib/registration.dart index 23c55c6..87661e8 100644 --- a/ui/grom/lib/registration.dart +++ b/ui/grom/lib/registration.dart @@ -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 { @@ -71,7 +72,11 @@ class _RegistrationFormState extends State { 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( diff --git a/ui/grom/lib/server_url_resolver.dart b/ui/grom/lib/server_url_resolver.dart new file mode 100644 index 0000000..95237de --- /dev/null +++ b/ui/grom/lib/server_url_resolver.dart @@ -0,0 +1,101 @@ +import 'server_storage.dart'; +import 'platform/server_scheme_probe.dart'; + +export 'platform/server_scheme_probe_result.dart'; + +typedef SchemeProbe = Future 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 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; +} diff --git a/ui/grom/lib/widgets/server_url_field.dart b/ui/grom/lib/widgets/server_url_field.dart index bd1918d..5855d86 100644 --- a/ui/grom/lib/widgets/server_url_field.dart +++ b/ui/grom/lib/widgets/server_url_field.dart @@ -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, diff --git a/ui/grom/test/server_url_resolver_test.dart b/ui/grom/test/server_url_resolver_test.dart new file mode 100644 index 0000000..dce5d40 --- /dev/null +++ b/ui/grom/test/server_url_resolver_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:grom/server_url_resolver.dart'; + +void main() { + group('needsSchemeProbe', () { + test('true for bare host and host with path', () { + expect(needsSchemeProbe('grom.msk.ru'), isTrue); + expect(needsSchemeProbe('grom.example/prefix'), isTrue); + expect(needsSchemeProbe(' grom.example '), isTrue); + }); + + test('false when scheme or port is explicit', () { + expect(needsSchemeProbe('https://grom.example'), isFalse); + expect(needsSchemeProbe('http://grom.example'), isFalse); + expect(needsSchemeProbe('HTTP://grom.example'), isFalse); + expect(needsSchemeProbe('grom.example:8443'), isFalse); + expect(needsSchemeProbe('ftp://grom.example'), isFalse); + expect(needsSchemeProbe(''), isFalse); + }); + + test('IPv6 brackets: probe when no port, skip when port set', () { + // No explicit port → probe is allowed (hasPort is false). + expect(needsSchemeProbe('[::1]'), isTrue); + expect(needsSchemeProbe('[::1]:8080'), isFalse); + }); + }); + + group('baseUrlFromStatusUri', () { + test('strips status path and keeps redirect host/scheme', () { + expect( + baseUrlFromStatusUri( + Uri.parse('https://www.example.com/api/v1/status'), + ), + 'https://www.example.com', + ); + expect( + baseUrlFromStatusUri( + Uri.parse('https://grom.example/prefix/api/v1/status'), + ), + 'https://grom.example/prefix', + ); + expect( + baseUrlFromStatusUri( + Uri.parse('http://192.168.1.10:8080/api/v1/status'), + ), + 'http://192.168.1.10:8080', + ); + }); + }); + + group('resolveServerBaseUrl', () { + test('skips probe when scheme or port provided', () async { + var probed = false; + final resolved = await resolveServerBaseUrl( + 'https://grom.example/', + probe: (_) async { + probed = true; + return const SchemeProbeResult(reached: false); + }, + ); + expect(probed, isFalse); + expect(resolved, 'https://grom.example'); + + final withPort = await resolveServerBaseUrl( + 'grom.example:8443', + probe: (_) async { + probed = true; + return const SchemeProbeResult(reached: false); + }, + ); + expect(withPort, 'https://grom.example:8443'); + }); + + test('uses https when https probe reaches', () async { + final resolved = await resolveServerBaseUrl( + 'grom.msk.ru', + probe: (uri) async { + expect(uri.scheme, 'https'); + expect(uri.path, '/api/v1/status'); + return SchemeProbeResult(reached: true, finalUri: uri); + }, + ); + expect(resolved, 'https://grom.msk.ru'); + }); + + test('uses https when TLS is present but handshake fails', () async { + final resolved = await resolveServerBaseUrl( + 'grom.example', + probe: (uri) async { + if (uri.scheme == 'https') { + return const SchemeProbeResult(reached: false, tlsPresent: true); + } + fail('should not probe http after tlsPresent'); + }, + ); + expect(resolved, 'https://grom.example'); + }); + + test('falls back to http when https unreachable', () async { + final resolved = await resolveServerBaseUrl( + '192.168.1.10', + probe: (uri) async { + if (uri.scheme == 'https') { + return const SchemeProbeResult(reached: false); + } + return SchemeProbeResult( + reached: true, + finalUri: Uri.parse('http://192.168.1.10/api/v1/status'), + ); + }, + ); + expect(resolved, 'http://192.168.1.10'); + }); + + test('falls back to https when both probes fail', () async { + final resolved = await resolveServerBaseUrl( + 'offline.example', + probe: (_) async => const SchemeProbeResult(reached: false), + ); + expect(resolved, 'https://offline.example'); + }); + + test('preserves path prefix and follows redirect final URI', () async { + final resolved = await resolveServerBaseUrl( + 'grom.example/app', + probe: (uri) async { + expect(uri.toString(), 'https://grom.example/app/api/v1/status'); + return SchemeProbeResult( + reached: true, + finalUri: Uri.parse('https://cdn.example/app/api/v1/status'), + ); + }, + ); + expect(resolved, 'https://cdn.example/app'); + }); + + test('http probe redirect to https uses final base', () async { + final resolved = await resolveServerBaseUrl( + 'grom.example', + probe: (uri) async { + if (uri.scheme == 'https') { + return const SchemeProbeResult(reached: false); + } + return SchemeProbeResult( + reached: true, + finalUri: Uri.parse('https://grom.example/api/v1/status'), + ); + }, + ); + expect(resolved, 'https://grom.example'); + }); + }); +}