From fb4614129fe5820c4e93c1db4429c47d7c44efba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=81ngel=20Tabar=20Eito?= Date: Fri, 3 Jul 2026 19:23:47 +0200 Subject: [PATCH 1/2] chore: release 0.10.1 --- .gitignore | 9 +++ CHANGELOG.md | 24 ++++++++ example/.metadata | 12 ++-- example/lib/app.dart | 33 ++++++++--- example/lib/chat_session.dart | 18 +++--- example/lib/onboarding_page.dart | 6 +- example/lib/settings/example_settings.dart | 9 ++- flutter_01.log | 62 --------------------- flutter_02.log | 62 --------------------- lib/src/_internal/http/rest_client.dart | 2 +- lib/src/_internal/mappers/room_mapper.dart | 6 ++ lib/src/config/chat_config.dart | 25 +++++++-- lib/src/ui/services/attachment_pickers.dart | 8 +++ lib/src/ui/utils/attachment_opener.dart | 38 +++++++++++-- lib/src/ui/utils/last_message_preview.dart | 9 ++- lib/src/ui/utils/platform_support.dart | 33 +++++++++++ lib/src/ui/widgets/avatar_crop_page.dart | 4 ++ lib/src/ui/widgets/avatar_picker_sheet.dart | 19 +++++-- lib/src/ui/widgets/noma_chat_view.dart | 5 +- pubspec.yaml | 2 +- test/sdk/config/chat_config_test.dart | 25 ++++++--- 21 files changed, 226 insertions(+), 185 deletions(-) delete mode 100644 flutter_01.log delete mode 100644 flutter_02.log create mode 100644 lib/src/ui/utils/platform_support.dart diff --git a/.gitignore b/.gitignore index 5231ac1..f843e0e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,12 @@ coverage/ # macOS / OS .DS_Store Thumbs.db + +# Logs (stray flutter run/build logs) +*.log + +# Example desktop/web runners (regenerate with `flutter create .`) +example/linux/ +example/macos/ +example/windows/ +example/web/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e69bf75..f9a8e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the package follows [Semantic Versioning](https://semver.org/). From `1.0.0` onwards, breaking changes require a **major version bump**. +## [0.10.1] - 2026-07-03 + +### Added + +- **Cross-platform capability gating (`PlatformSupport`).** Attachment and + avatar UI now degrade gracefully on platforms whose plugins do not cover + every target: camera capture and image crop are offered on mobile (crop on + mobile only), while downloaded files open natively on mobile and fall back to + the OS default handler via `url_launcher` on desktop. Derived from `kIsWeb` + + `defaultTargetPlatform` (never `dart:io`), so it resolves on web too, hiding + controls a platform cannot honour instead of surfacing ones that silently + fail. +- **Example app now builds for desktop and web** (Linux, macOS, Windows, web) + in addition to Android and iOS. + +### Changed + +- **`ChatConfig` URL validation exempts loopback hosts from the release-mode + HTTPS requirement.** `http://` to `localhost`, `127.0.0.0/8`, or `::1` stays + allowed in release builds — loopback traffic never leaves the device and + every platform treats it as a secure context — while every other host still + requires `https://` (pentest M-10). The `127.` match is anchored to an IPv4 + literal so a DNS host such as `127.evil.com` is not mistaken for loopback. + ## [0.10.0] - 2026-06-17 ### Added diff --git a/example/.metadata b/example/.metadata index 37a9795..a49b961 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "f6ff1529fd6d8af5f706051d9251ac9231c83407" + revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" channel: "stable" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - - platform: ios - create_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 - base_revision: f6ff1529fd6d8af5f706051d9251ac9231c83407 + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + - platform: web + create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 + base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 # User provided section diff --git a/example/lib/app.dart b/example/lib/app.dart index 65197f6..25d219f 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -42,9 +42,12 @@ class _NomaChatExampleAppState extends State { Future _bootstrap() async { var stored = await _storage.load(); - // Defensive migration: an earlier build used the SDK's generic ws/sse - // paths (`/ws`, `/events`). Real CHT routes are `/v1/ws` and `/v1/events`. - if (stored.wsPath == '/ws' || stored.ssePath == '/events') { + // Defensive migration: older builds persisted the SDK generic `/ws` or a + // stale SSE path (`/events`, `/v1/events`). CHT serves WS at `/v1/ws` and + // SSE at `/eventsource` (NRTE); refresh stale persisted paths to current. + if (stored.wsPath == '/ws' || + stored.ssePath == '/events' || + stored.ssePath == '/v1/events') { const fresh = ExampleSettings(); stored = stored.copyWith(wsPath: fresh.wsPath, ssePath: fresh.ssePath); await _storage.save(stored); @@ -72,13 +75,27 @@ class _NomaChatExampleAppState extends State { if (stored == const ExampleSettings()) { s = stored.copyWith(mode: chatModeFromEnv()); } + s = applySseEnvOverride(s); setState(() => _settings = s); - // Demo-app philosophy: every cold start lands on the onboarding - // (mock/real segmented control). The previous "auto-restore CHT - // session" path was dropped 2026-05-25 — keeping you on the last - // screen made sense for a chat app, but this is a docs / lab / - // demo, not your production chat client. + // Session persistence: if the user logged in on a previous run (and did + // not log out — logout clears the saved username), restore that session on + // cold start so they stay logged in. On failure (e.g. backend unreachable) + // fall through to the onboarding, pre-filled, so they can retry. + if (s.username.isNotEmpty && + s.baseUrl.isNotEmpty && + s.realtimeUrl.isNotEmpty) { + final outcome = await openChatSession(s, onAuthFailure: _onAuthFailure); + if (!mounted) return; + if (outcome is LoginSuccess) { + _wireRoomRemovedSnackbar(outcome.chat); + setState(() { + _chat = outcome.chat; + _bootstrapping = false; + }); + return; + } + } if (mounted) setState(() => _bootstrapping = false); } diff --git a/example/lib/chat_session.dart b/example/lib/chat_session.dart index 6ae33ad..7f4210d 100644 --- a/example/lib/chat_session.dart +++ b/example/lib/chat_session.dart @@ -50,6 +50,11 @@ String _sseUrlOverride() => String _ssePathOverride() => const String.fromEnvironment('SSE_PATH', defaultValue: ''); +ExampleSettings applySseEnvOverride(ExampleSettings s) => s.copyWith( + sseUrl: s.sseUrl ?? (_sseUrlOverride().isNotEmpty ? _sseUrlOverride() : null), + ssePath: _ssePathOverride().isNotEmpty ? _ssePathOverride() : s.ssePath, +); + /// ChatResult of a register-or-login attempt. sealed class LoginOutcome { const LoginOutcome(); @@ -135,6 +140,7 @@ Future openChatSession( maxRooms: 100, ); + final resolved = applySseEnvOverride(settings); final config = ChatConfig.withAuthInterceptor( baseUrl: settings.baseUrl, realtimeUrl: settings.realtimeUrl, @@ -152,17 +158,9 @@ Future openChatSession( // the example app can route to the login flow autonomously. onAuthFailure: onAuthFailure, ), - // The harness pre-fills SSE_URL / SSE_PATH dart-defines (CHT - // routes SSE through NRTE on :2082/eventsource, not the REST - // strip on :8077). User-typed settings take priority over the - // dart-define so manual onboarding still wins. - sseUrl: - settings.sseUrl ?? - (_sseUrlOverride().isNotEmpty ? _sseUrlOverride() : null), + sseUrl: resolved.sseUrl, wsPath: settings.wsPath, - ssePath: _ssePathOverride().isNotEmpty - ? _ssePathOverride() - : settings.ssePath, + ssePath: resolved.ssePath, requestTimeout: Duration(seconds: settings.requestTimeoutSeconds), wsReconnectDelay: Duration(seconds: settings.wsReconnectDelaySeconds), maxReconnectAttempts: settings.maxReconnectAttempts, diff --git a/example/lib/onboarding_page.dart b/example/lib/onboarding_page.dart index 39587a9..55b14ab 100644 --- a/example/lib/onboarding_page.dart +++ b/example/lib/onboarding_page.dart @@ -179,7 +179,7 @@ class _OnboardingPageState extends State { final (wsBase, wsPath) = _splitUrl(_wsUrl.text, defaultPath: '/v1/ws'); final (sseBase, ssePath) = _splitUrl( _sseUrl.text, - defaultPath: '/v1/events', + defaultPath: '/eventsource', ); final sseUrlOverride = sseBase.isEmpty || sseBase == wsBase ? null @@ -245,7 +245,7 @@ class _OnboardingPageState extends State { body: SafeArea( child: Center( child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 480), + constraints: const BoxConstraints(maxWidth: double.infinity), // Column splits the screen in two: a scrollable upper area // for the form and a fixed footer that always carries the // CTA. Lets long forms (CHT mode) scroll without ever @@ -691,7 +691,7 @@ class _BackendSection extends StatelessWidget { enabled: enabled, decoration: InputDecoration( labelText: strings.sseUrlLabel, - hintText: 'http://localhost:8077/v1/events', + hintText: 'http://localhost:2082/eventsource', border: const OutlineInputBorder(), isDense: true, ), diff --git a/example/lib/settings/example_settings.dart b/example/lib/settings/example_settings.dart index 5400ca4..d6d5c8f 100644 --- a/example/lib/settings/example_settings.dart +++ b/example/lib/settings/example_settings.dart @@ -26,13 +26,12 @@ class ExampleSettings { // cache (instant startup, offline read, offline queue). this.requestTimeoutSeconds = 30, this.wsReconnectDelaySeconds = 2, - // Tier 3 — Very advanced. Defaults match CHT user_client mount points - // (see `apps/user_client/src/user_client.erl` cowboy routes). The SDK - // ChatConfig fallback (`/ws`, `/events`) targets a generic backend; for - // CHT we override here so the example connects out-of-the-box. + // Tier 3 — Very advanced. wsPath overrides the SDK generic `/ws` to + // CHT's `/v1/ws` mount; ssePath matches the SDK default `/eventsource` + // (CHT's NRTE mount). SSE host/port still needs config — see env_gen. this.sseUrl, this.wsPath = '/v1/ws', - this.ssePath = '/v1/events', + this.ssePath = '/eventsource', this.maxReconnectAttempts, this.eventBufferSize = 20, this.languageCode, diff --git a/flutter_01.log b/flutter_01.log deleted file mode 100644 index 9efd262..0000000 --- a/flutter_01.log +++ /dev/null @@ -1,62 +0,0 @@ -Flutter crash report. -Please report a bug at https://github.com/flutter/flutter/issues. - -## command - -flutter test --reporter expanded - -## exception - -FileSystemException: FileSystemException: Failed to decode data using encoding 'utf-8', path = '/Users/angel.tabar/Documents/proyectos/noma_chat_flutter/test/ui/adapter/last_message_sender_name_test.dart' - -``` -#0 _File._tryDecode (dart:io/file_impl.dart:708:7) -#1 _File.readAsStringSync (dart:io/file_impl.dart:719:7) -#2 Loader.loadFile (package:test_core/src/runner/loader.dart:162:32) - -``` - -## flutter doctor - -``` -[✓] Flutter (Channel stable, 3.38.5, on macOS 26.5 25F71 darwin-arm64, locale es-ES) [850ms] - • Flutter version 3.38.5 on channel stable at /Users/angel.tabar/flutter - • Upstream repository https://github.com/flutter/flutter.git - • Framework revision f6ff1529fd (hace 6 meses), 2025-12-11 11:50:07 -0500 - • Engine revision 1527ae0ec5 - • Dart version 3.10.4 - • DevTools version 2.51.1 - • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging - -[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [6,0s] - • Android SDK at /Users/angel.tabar/Library/Android/sdk - • Emulator version 35.2.10.0 (build_id 12414864) (CL:N/A) - • Platform android-36, build-tools 36.0.0 - • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java - This is the JDK bundled with the latest Android Studio installation on this machine. - To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`. - • Java version OpenJDK Runtime Environment (build 21.0.3+-79915917-b509.11) - • All Android licenses accepted. - -[✓] Xcode - develop for iOS and macOS (Xcode 26.5) [1.841ms] - • Xcode at /Applications/Xcode.app/Contents/Developer - • Build 17F42 - • CocoaPods version 1.16.2 - -[✓] Chrome - develop for the web [14ms] - • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome - -[✓] Connected device (5 available) [6,8s] - • harness-noma-alice (mobile) • FE051B9C-2019-4A2E-82B2-E440618690FF • ios • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator) - • harness-noma-bob (mobile) • D2A34754-0FD5-49AE-9D61-827EE352A3B6 • ios • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator) - • harness-noma-charlie (mobile) • DED5EF32-5799-4CE4-84F7-CE6800136B9E • ios • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator) - • macOS (desktop) • macos • darwin-arm64 • macOS 26.5 25F71 darwin-arm64 - • Chrome (web) • chrome • web-javascript • Google Chrome 148.0.7778.168 - ! Error: Browsing on the local area network for iPhone de Angel . Ensure the device is unlocked and attached with a cable or associated with the same local area network as this Mac. - The device must be opted into Developer Mode to connect wirelessly. (code -27) - -[✓] Network resources [489ms] - • All expected network resources are available. - -• No issues found! -``` diff --git a/flutter_02.log b/flutter_02.log deleted file mode 100644 index f40e249..0000000 --- a/flutter_02.log +++ /dev/null @@ -1,62 +0,0 @@ -Flutter crash report. -Please report a bug at https://github.com/flutter/flutter/issues. - -## command - -flutter test --reporter expanded - -## exception - -FileSystemException: FileSystemException: Failed to decode data using encoding 'utf-8', path = '/Users/angel.tabar/Documents/proyectos/noma_chat_flutter/test/ui/adapter/last_message_sender_name_test.dart' - -``` -#0 _File._tryDecode (dart:io/file_impl.dart:708:7) -#1 _File.readAsStringSync (dart:io/file_impl.dart:719:7) -#2 Loader.loadFile (package:test_core/src/runner/loader.dart:162:32) - -``` - -## flutter doctor - -``` -[✓] Flutter (Channel stable, 3.38.5, on macOS 26.5 25F71 darwin-arm64, locale es-ES) [455ms] - • Flutter version 3.38.5 on channel stable at /Users/angel.tabar/flutter - • Upstream repository https://github.com/flutter/flutter.git - • Framework revision f6ff1529fd (hace 6 meses), 2025-12-11 11:50:07 -0500 - • Engine revision 1527ae0ec5 - • Dart version 3.10.4 - • DevTools version 2.51.1 - • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging - -[✓] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [1.253ms] - • Android SDK at /Users/angel.tabar/Library/Android/sdk - • Emulator version 35.2.10.0 (build_id 12414864) (CL:N/A) - • Platform android-36, build-tools 36.0.0 - • Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java - This is the JDK bundled with the latest Android Studio installation on this machine. - To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`. - • Java version OpenJDK Runtime Environment (build 21.0.3+-79915917-b509.11) - • All Android licenses accepted. - -[✓] Xcode - develop for iOS and macOS (Xcode 26.5) [941ms] - • Xcode at /Applications/Xcode.app/Contents/Developer - • Build 17F42 - • CocoaPods version 1.16.2 - -[✓] Chrome - develop for the web [13ms] - • Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome - -[✓] Connected device (5 available) [6,3s] - • harness-noma-alice (mobile) • FE051B9C-2019-4A2E-82B2-E440618690FF • ios • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator) - • harness-noma-bob (mobile) • D2A34754-0FD5-49AE-9D61-827EE352A3B6 • ios • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator) - • harness-noma-charlie (mobile) • DED5EF32-5799-4CE4-84F7-CE6800136B9E • ios • com.apple.CoreSimulator.SimRuntime.iOS-26-5 (simulator) - • macOS (desktop) • macos • darwin-arm64 • macOS 26.5 25F71 darwin-arm64 - • Chrome (web) • chrome • web-javascript • Google Chrome 148.0.7778.168 - ! Error: Browsing on the local area network for iPhone de Angel . Ensure the device is unlocked and attached with a cable or associated with the same local area network as this Mac. - The device must be opted into Developer Mode to connect wirelessly. (code -27) - -[✓] Network resources [327ms] - • All expected network resources are available. - -• No issues found! -``` diff --git a/lib/src/_internal/http/rest_client.dart b/lib/src/_internal/http/rest_client.dart index 6fd7423..f321e80 100644 --- a/lib/src/_internal/http/rest_client.dart +++ b/lib/src/_internal/http/rest_client.dart @@ -21,7 +21,7 @@ import 'retry_interceptor.dart'; /// if the two drift, so bumping the package version forces an update here /// too. A future build_runner-generated constant can drop in without /// changing the call sites. -const String nomaChatSdkVersion = '0.10.0'; +const String nomaChatSdkVersion = '0.10.1'; const String _requestIdExtraKey = 'requestId'; const Uuid _uuid = Uuid(); diff --git a/lib/src/_internal/mappers/room_mapper.dart b/lib/src/_internal/mappers/room_mapper.dart index 9bafb84..c5e582d 100644 --- a/lib/src/_internal/mappers/room_mapper.dart +++ b/lib/src/_internal/mappers/room_mapper.dart @@ -126,6 +126,12 @@ class RoomMapper { lastMessageReceipt = _parseReceiptStatus(_asString(lastMsg['receipt'])); } + if ((lastMessageType == null || lastMessageType == MessageType.regular) && + ((lastMessageMimeType != null && lastMessageMimeType.isNotEmpty) || + (lastMessageFileName != null && lastMessageFileName.isNotEmpty))) { + lastMessageType = MessageType.attachment; + } + return UnreadRoom( roomId: (json['roomId'] ?? '') as String, unreadMessages: (json['unreadMessages'] ?? 0) as int, diff --git a/lib/src/config/chat_config.dart b/lib/src/config/chat_config.dart index e232e7c..e4497ec 100644 --- a/lib/src/config/chat_config.dart +++ b/lib/src/config/chat_config.dart @@ -527,17 +527,34 @@ class ChatConfig { } // Plain http:// is allowed only during local development. Release builds - // must always use https:// — chat traffic carries JWTs and message - // bodies that we cannot afford to send in clear. See pentest M-10. - if (scheme == 'http' && isReleaseMode) { + // must use https:// for any host the traffic actually leaves the device + // to reach — chat traffic carries JWTs and message bodies we cannot send + // in clear. See pentest M-10. Loopback (localhost / 127.0.0.0/8 / ::1) is + // exempt: that traffic never reaches a wire, and every platform treats + // localhost as a secure context, so http to loopback stays allowed. + if (scheme == 'http' && isReleaseMode && !_isLoopbackHost(value)) { throw ArgumentError.value( value, field, - 'http:// is not allowed in release builds; use https://.', + 'http:// is not allowed in release builds for non-loopback hosts; ' + 'use https://.', ); } } } + /// True when [url]'s host is a loopback address — `localhost`, any + /// `127.0.0.0/8` address, or IPv6 `::1`. Loopback traffic never leaves the + /// device, so it is exempt from the release-mode https requirement enforced + /// in [validateUrls]. The `127.` match is anchored to an IPv4 literal so a + /// DNS host like `127.evil.com` is not mistaken for loopback. + static bool _isLoopbackHost(String url) { + final host = Uri.parse(url).host.toLowerCase(); + if (host == 'localhost' || host == '::1' || host == '0:0:0:0:0:0:0:1') { + return true; + } + return RegExp(r'^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$').hasMatch(host); + } + void log(String level, String message) => logger?.call(level, message); } diff --git a/lib/src/ui/services/attachment_pickers.dart b/lib/src/ui/services/attachment_pickers.dart index e031e25..187a355 100644 --- a/lib/src/ui/services/attachment_pickers.dart +++ b/lib/src/ui/services/attachment_pickers.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart' as ip; import '../models/attachment_policy.dart'; +import '../utils/platform_support.dart'; /// ChatResult of an attachment picker call. /// @@ -47,6 +48,13 @@ class AttachmentPickers { AttachmentPolicy policy = AttachmentPolicy.unrestricted, void Function(String level, String message)? logger, }) async { + if (!PlatformSupport.supportsCameraCapture) { + logger?.call( + 'warn', + 'pickImageFromCamera unsupported on this platform; ignoring', + ); + return null; + } try { final file = await _imagePicker.pickImage( source: ip.ImageSource.camera, diff --git a/lib/src/ui/utils/attachment_opener.dart b/lib/src/ui/utils/attachment_opener.dart index 8d76aef..ab32fb8 100644 --- a/lib/src/ui/utils/attachment_opener.dart +++ b/lib/src/ui/utils/attachment_opener.dart @@ -1,10 +1,13 @@ import 'dart:io'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../client/chat_client.dart'; import '../../core/result.dart'; +import 'platform_support.dart'; /// Default "open this attachment" handler the SDK wires when the consumer /// does not supply its own `onTapFile` / `onTapDoc`. @@ -24,6 +27,16 @@ Future openAttachmentFile({ String? mimeType, void Function(String level, String message)? logger, }) async { + if (kIsWeb) { + // Web has no local filesystem to stage a temp file in; hand the URL to the + // browser (new tab), which previews or downloads it natively. + try { + await launchUrl(Uri.parse(url), webOnlyWindowName: '_blank'); + } catch (e) { + logger?.call('warn', 'open attachment (web) threw: $e'); + } + return; + } try { final result = await client.attachments.downloadFromUrl(url); switch (result) { @@ -35,12 +48,25 @@ Future openAttachmentFile({ final name = _tempFileName(fileName: fileName, mimeType: mimeType); final file = File('${dir.path}/$name'); await file.writeAsBytes(data, flush: true); - final open = await OpenFilex.open(file.path); - if (open.type != ResultType.done) { - logger?.call( - 'warn', - 'open attachment failed: ${open.type.name} ${open.message}', - ); + if (PlatformSupport.opensFilesNatively) { + final open = await OpenFilex.open(file.path); + if (open.type != ResultType.done) { + logger?.call( + 'warn', + 'open attachment failed: ${open.type.name} ${open.message}', + ); + } + } else { + // open_filex is android/ios only — on desktop hand the file to the + // OS default handler via url_launcher (NSWorkspace / ShellExecute / + // xdg-open). + final opened = await launchUrl(Uri.file(file.path)); + if (!opened) { + logger?.call( + 'warn', + 'open attachment: no OS handler for ${file.path}', + ); + } } } } catch (e) { diff --git a/lib/src/ui/utils/last_message_preview.dart b/lib/src/ui/utils/last_message_preview.dart index da6a975..4c1c272 100644 --- a/lib/src/ui/utils/last_message_preview.dart +++ b/lib/src/ui/utils/last_message_preview.dart @@ -30,7 +30,14 @@ String? buildLastMessagePreview( return isMine ? l10n.previewDeletedByYou : l10n.previewDeletedByOther; } - final type = item.lastMessageType; + var type = item.lastMessageType; + if ((type == null || type == MessageType.regular) && + ((item.lastMessageMimeType != null && + item.lastMessageMimeType!.isNotEmpty) || + (item.lastMessageFileName != null && + item.lastMessageFileName!.isNotEmpty))) { + type = MessageType.attachment; + } if (type == null) { final legacy = item.lastMessage; return (legacy != null && legacy.isNotEmpty) ? legacy : null; diff --git a/lib/src/ui/utils/platform_support.dart b/lib/src/ui/utils/platform_support.dart new file mode 100644 index 0000000..7850741 --- /dev/null +++ b/lib/src/ui/utils/platform_support.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; + +/// Capability gates for features whose underlying plugins do not cover every +/// target platform. Derived from [kIsWeb] + [defaultTargetPlatform] (never +/// `dart:io`) so it resolves correctly on every platform the SDK builds for, +/// web included. The UI uses these to hide options the platform cannot honour +/// instead of surfacing a control that silently fails. +class PlatformSupport { + PlatformSupport._(); + + static bool get _isMobile => + !kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.android); + + /// `image_picker` implements camera capture on mobile (and on web via + /// ``). Its desktop federated plugins delegate gallery picks + /// to a file dialog and throw `StateError` on `ImageSource.camera`, so + /// camera capture is unavailable on macOS / Windows / Linux. + static bool get supportsCameraCapture => _isMobile || kIsWeb; + + /// Crop is offered on mobile only. There is no native cropper for + /// macOS / Windows / Linux, and the SDK's crop path stages the image through + /// a `dart:io` temp file, which is unavailable on web — so on every + /// non-mobile target the crop step is skipped and the picked image is used + /// as-is. (`image_cropper`'s web delegate could be wired later for web crop.) + static bool get supportsImageCrop => _isMobile; + + /// `open_filex` ships android / ios only. On desktop the SDK opens the + /// downloaded file through `url_launcher` (a `file://` URI handed to the OS + /// default handler) instead. + static bool get opensFilesNatively => _isMobile; +} diff --git a/lib/src/ui/widgets/avatar_crop_page.dart b/lib/src/ui/widgets/avatar_crop_page.dart index 9aa0450..062034b 100644 --- a/lib/src/ui/widgets/avatar_crop_page.dart +++ b/lib/src/ui/widgets/avatar_crop_page.dart @@ -6,6 +6,7 @@ import 'package:image_cropper/image_cropper.dart'; import 'package:path_provider/path_provider.dart'; import '../theme/chat_theme.dart'; +import '../utils/platform_support.dart'; /// Square-aspect-ratio crop using `image_cropper`'s native UIs /// (TOCropViewController on iOS, UCrop on Android). The picked image is @@ -23,6 +24,9 @@ class AvatarCropPage { int compressQuality = 85, ChatTheme theme = ChatTheme.defaults, }) async { + // No native cropper on desktop (image_cropper is android/ios/web only). + // Degrade gracefully: return the source so the caller uploads it uncropped. + if (!PlatformSupport.supportsImageCrop) return sourceBytes; File? tempFile; try { final tempDir = await getTemporaryDirectory(); diff --git a/lib/src/ui/widgets/avatar_picker_sheet.dart b/lib/src/ui/widgets/avatar_picker_sheet.dart index 1af776d..e5502ca 100644 --- a/lib/src/ui/widgets/avatar_picker_sheet.dart +++ b/lib/src/ui/widgets/avatar_picker_sheet.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../../storage/avatar_storage.dart'; import '../services/attachment_pickers.dart'; import '../theme/chat_theme.dart'; +import '../utils/platform_support.dart'; import 'avatar_crop_page.dart'; import 'image_viewer.dart'; @@ -79,11 +80,12 @@ class AvatarPickerSheet { ], ), ), - ListTile( - leading: const Icon(Icons.photo_camera_outlined), - title: Text(theme.l10n.takePhoto), - onTap: () => Navigator.of(sheetCtx).pop(_Source.camera), - ), + if (PlatformSupport.supportsCameraCapture) + ListTile( + leading: const Icon(Icons.photo_camera_outlined), + title: Text(theme.l10n.takePhoto), + onTap: () => Navigator.of(sheetCtx).pop(_Source.camera), + ), ListTile( leading: const Icon(Icons.photo_library_outlined), title: Text(theme.l10n.chooseFromGallery), @@ -129,7 +131,12 @@ class AvatarPickerSheet { ); if (cropped == null) return const AvatarPickerCancelled(); return AvatarPicked( - AvatarSnapshot(bytes: cropped, mimeType: 'image/jpeg'), + AvatarSnapshot( + bytes: cropped, + mimeType: PlatformSupport.supportsImageCrop + ? 'image/jpeg' + : picked.mimeType, + ), ); case _Source.view: await Navigator.of(context).push( diff --git a/lib/src/ui/widgets/noma_chat_view.dart b/lib/src/ui/widgets/noma_chat_view.dart index 8ef2225..acd715e 100644 --- a/lib/src/ui/widgets/noma_chat_view.dart +++ b/lib/src/ui/widgets/noma_chat_view.dart @@ -13,6 +13,7 @@ import '../models/room_list_item.dart'; import '../services/attachment_pickers.dart'; import '../theme/chat_theme.dart'; import '../utils/attachment_opener.dart'; +import '../utils/platform_support.dart'; import 'chat_room_app_bar.dart'; import 'chat_view.dart'; import 'message_context_menu.dart'; @@ -562,7 +563,9 @@ class _NomaChatViewState extends State { ), onPickCamera: user.onPickCamera ?? - () => _pickAndSendImage(sendKey, fromCamera: true), + (PlatformSupport.supportsCameraCapture + ? () => _pickAndSendImage(sendKey, fromCamera: true) + : null), onPickGallery: user.onPickGallery ?? () => _pickAndSendImage(sendKey, fromCamera: false), diff --git a/pubspec.yaml b/pubspec.yaml index 07ea44f..9b829bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -3,7 +3,7 @@ description: >- Plug & play Flutter chat: SDK with REST + real-time client, offline Hive cache, UI adapter and ready-to-use UI components for the Nomasystems chat backend. -version: 0.10.0 +version: 0.10.1 homepage: https://github.com/nomasystems/noma_chat_flutter repository: https://github.com/nomasystems/noma_chat_flutter diff --git a/test/sdk/config/chat_config_test.dart b/test/sdk/config/chat_config_test.dart index 6baf197..fad0614 100644 --- a/test/sdk/config/chat_config_test.dart +++ b/test/sdk/config/chat_config_test.dart @@ -92,15 +92,22 @@ void main() { ); }); - test('rejects http://localhost (no special-case for loopback)', () { - expect( - () => ChatConfig.validateUrls( - baseUrl: 'http://localhost:8077/v1', - realtimeUrl: 'https://api.example.com', - isReleaseMode: isReleaseMode, - ), - throwsA(isA()), - ); + test('allows http:// to loopback hosts (secure context)', () { + for (final url in [ + 'http://localhost:8077', + 'http://127.0.0.1:8077', + 'http://[::1]:8077', + ]) { + expect( + () => ChatConfig.validateUrls( + baseUrl: '$url/v1', + realtimeUrl: url, + isReleaseMode: isReleaseMode, + ), + returnsNormally, + reason: '$url is loopback and never leaves the device', + ); + } }); }); From fe6fe820291bd479b7f3654077e99ee87c3435a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?A=CC=81ngel=20Tabar=20Eito?= Date: Fri, 3 Jul 2026 20:11:11 +0200 Subject: [PATCH 2/2] fix: await attachment picker results inside try; align example ssePath test to sdk default --- example/test/settings_test.dart | 2 +- lib/src/ui/services/attachment_pickers.dart | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/example/test/settings_test.dart b/example/test/settings_test.dart index bc75f16..fbb7144 100644 --- a/example/test/settings_test.dart +++ b/example/test/settings_test.dart @@ -17,7 +17,7 @@ void main() { expect(s.wsReconnectDelaySeconds, 2); expect(s.sseUrl, isNull); expect(s.wsPath, '/v1/ws'); - expect(s.ssePath, '/v1/events'); + expect(s.ssePath, '/eventsource'); expect(s.maxReconnectAttempts, isNull); expect(s.eventBufferSize, 20); }); diff --git a/lib/src/ui/services/attachment_pickers.dart b/lib/src/ui/services/attachment_pickers.dart index 187a355..a662a0e 100644 --- a/lib/src/ui/services/attachment_pickers.dart +++ b/lib/src/ui/services/attachment_pickers.dart @@ -60,7 +60,7 @@ class AttachmentPickers { source: ip.ImageSource.camera, imageQuality: imageQuality, ); - return _xfileToValidatedResult(file, policy, logger); + return await _xfileToValidatedResult(file, policy, logger); } on Object catch (e) { logger?.call('warn', 'pickImageFromCamera failed: $e'); return null; @@ -77,7 +77,7 @@ class AttachmentPickers { source: ip.ImageSource.gallery, imageQuality: imageQuality, ); - return _xfileToValidatedResult(file, policy, logger); + return await _xfileToValidatedResult(file, policy, logger); } on Object catch (e) { logger?.call('warn', 'pickImageFromGallery failed: $e'); return null; @@ -94,7 +94,7 @@ class AttachmentPickers { source: ip.ImageSource.gallery, maxDuration: maxDuration, ); - return _xfileToValidatedResult( + return await _xfileToValidatedResult( file, policy, logger,