diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 834f5dce9f..d1ddbbbd44 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,22 +1,70 @@ PODS: - device_info_plus (0.0.1): - Flutter + - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.4) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Flutter (1.0.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - SDWebImage (5.15.5): + - SDWebImage/Core (= 5.15.5) + - SDWebImage/Core (5.15.5) - share_plus (0.0.1): - Flutter + - SwiftyGif (5.4.4) DEPENDENCIES: - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - share_plus (from `.symlinks/plugins/share_plus/ios`) +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + EXTERNAL SOURCES: device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter path_provider_foundation: @@ -26,9 +74,14 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: ce3938a0df3cc1ef404671531facef740d03f920 Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9 + SDWebImage: fd7e1a22f00303e058058278639bf6196ee431fe share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 + SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f PODFILE CHECKSUM: 985e5b058f26709dc81f9ae74ea2b2775bdbcefe diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index a3c26d885f..6ddd93282d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -49,5 +49,9 @@ UIApplicationSupportsIndirectInputEvents + UIBackgroundModes + + fetch + diff --git a/lib/api/core.dart b/lib/api/core.dart index 6da65a95a6..a3464810c7 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -32,9 +32,13 @@ abstract class ApiConnection { // that ensures nothing assumes base class has a real API key final Auth auth; + void close(); + Future get(String route, Map? params); Future post(String route, Map? params); + + Future postFileFromStream(String route, Stream> content, int length, { String? filename }); } // TODO memoize @@ -49,10 +53,22 @@ Map authHeader(Auth auth) { class LiveApiConnection extends ApiConnection { LiveApiConnection({required super.auth}); + final http.Client _client = http.Client(); + + bool _isOpen = true; + + @override + void close() { + assert(_isOpen); + _client.close(); + _isOpen = false; + } + Map _headers() => authHeader(auth); @override Future get(String route, Map? params) async { + assert(_isOpen); final baseUrl = Uri.parse(auth.realmUrl); final url = Uri( scheme: baseUrl.scheme, @@ -62,7 +78,7 @@ class LiveApiConnection extends ApiConnection { path: "/api/v1/$route", queryParameters: encodeParameters(params)); if (kDebugMode) print("GET $url"); - final response = await http.get(url, headers: _headers()); + final response = await _client.get(url, headers: _headers()); if (response.statusCode != 200) { throw Exception("error on GET $route: status ${response.statusCode}"); } @@ -71,7 +87,8 @@ class LiveApiConnection extends ApiConnection { @override Future post(String route, Map? params) async { - final response = await http.post( + assert(_isOpen); + final response = await _client.post( Uri.parse("${auth.realmUrl}/api/v1/$route"), headers: _headers(), body: encodeParameters(params)); @@ -80,6 +97,19 @@ class LiveApiConnection extends ApiConnection { } return utf8.decode(response.bodyBytes); } + + @override + Future postFileFromStream(String route, Stream> content, int length, { String? filename }) async { + assert(_isOpen); + http.MultipartRequest request = http.MultipartRequest('POST', Uri.parse("${auth.realmUrl}/api/v1/$route")) + ..files.add(http.MultipartFile('file', content, length, filename: filename)) + ..headers.addAll(_headers()); + final response = await http.Response.fromStream(await _client.send(request)); + if (response.statusCode != 200) { + throw Exception("error on file-upload POST $route: status ${response.statusCode}"); + } + return utf8.decode(response.bodyBytes); + } } Map? encodeParameters(Map? params) { diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 9a92038200..4feaff093e 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -21,6 +21,8 @@ class InitialSnapshot { final List subscriptions; + final int maxFileUploadSizeMib; + // TODO etc., etc. InitialSnapshot({ @@ -32,6 +34,7 @@ class InitialSnapshot { required this.alertWords, required this.customProfileFields, required this.subscriptions, + required this.maxFileUploadSizeMib, }); factory InitialSnapshot.fromJson(Map json) => diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index d70fbdb3b1..55e2d5555e 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -22,6 +22,7 @@ InitialSnapshot _$InitialSnapshotFromJson(Map json) => subscriptions: (json['subscriptions'] as List) .map((e) => Subscription.fromJson(e as Map)) .toList(), + maxFileUploadSizeMib: json['max_file_upload_size_mib'] as int, ); Map _$InitialSnapshotToJson(InitialSnapshot instance) => @@ -34,4 +35,5 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => 'alert_words': instance.alertWords, 'custom_profile_fields': instance.customProfileFields, 'subscriptions': instance.subscriptions, + 'max_file_upload_size_mib': instance.maxFileUploadSizeMib, }; diff --git a/lib/api/route/messages.dart b/lib/api/route/messages.dart index 62f4d0b711..0fc4db27fe 100644 --- a/lib/api/route/messages.dart +++ b/lib/api/route/messages.dart @@ -97,3 +97,28 @@ class SendMessageResult { Map toJson() => _$SendMessageResultToJson(this); } + +/// https://zulip.com/api/upload-file +Future uploadFile( + ApiConnection connection, { + required Stream> content, + required int length, + required String filename, +}) async { + final data = await connection.postFileFromStream('user_uploads', content, length, filename: filename); + return UploadFileResult.fromJson(jsonDecode(data)); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UploadFileResult { + final String uri; + + UploadFileResult({ + required this.uri, + }); + + factory UploadFileResult.fromJson(Map json) => + _$UploadFileResultFromJson(json); + + Map toJson() => _$UploadFileResultToJson(this); +} diff --git a/lib/api/route/messages.g.dart b/lib/api/route/messages.g.dart index fe929c9278..dae949c63b 100644 --- a/lib/api/route/messages.g.dart +++ b/lib/api/route/messages.g.dart @@ -39,3 +39,13 @@ Map _$SendMessageResultToJson(SendMessageResult instance) => 'id': instance.id, 'deliver_at': instance.deliverAt, }; + +UploadFileResult _$UploadFileResultFromJson(Map json) => + UploadFileResult( + uri: json['uri'] as String, + ); + +Map _$UploadFileResultToJson(UploadFileResult instance) => + { + 'uri': instance.uri, + }; diff --git a/lib/model/store.dart b/lib/model/store.dart index 3feec9f1ca..67a1a815dd 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -142,13 +142,15 @@ class PerAccountStore extends ChangeNotifier { required InitialSnapshot initialSnapshot, }) : zulipVersion = initialSnapshot.zulipVersion, subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map( - (subscription) => MapEntry(subscription.streamId, subscription))); + (subscription) => MapEntry(subscription.streamId, subscription))), + maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib; final Account account; final ApiConnection connection; final String zulipVersion; final Map subscriptions; + final int maxFileUploadSizeMib; // No event for this. // TODO lots more data. When adding, be sure to update handleEvent too. @@ -294,7 +296,7 @@ class LivePerAccountStore extends PerAccountStore { final result = await getEvents(connection, queueId: queueId, lastEventId: lastEventId); // TODO handle errors on get-events; retry with backoff - // TODO abort long-poll on [dispose] + // TODO abort long-poll and close LiveApiConnection on [dispose] final events = result.events; for (final event in events) { handleEvent(event); diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 69ba6ded39..76da7eae2e 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -1,3 +1,4 @@ +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'dialog.dart'; @@ -44,9 +45,10 @@ class TopicTextEditingController extends TextEditingController { enum ContentValidationError { empty, - tooLong; + tooLong, + uploadInProgress; - // Later: upload in progress; quote-and-reply in progress + // Later: quote-and-reply in progress String message() { switch (this) { @@ -54,11 +56,73 @@ enum ContentValidationError { return "Message length shouldn't be greater than 10000 characters."; case ContentValidationError.empty: return 'You have nothing to send!'; + case ContentValidationError.uploadInProgress: + return 'Please wait for the upload to complete.'; } } } class ContentTextEditingController extends TextEditingController { + int _nextUploadTag = 0; + + final Map _uploads = {}; + + /// A probably-reasonable place to insert Markdown, such as for a file upload. + /// + /// Gives the cursor position, + /// or if text is selected, the end of the selection range. + /// + /// If there isn't a cursor position or a text selection + /// (e.g., when the input has never been focused), + /// gives the end of the whole text. + /// + /// Expressed as a collapsed `TextRange` at the index. + TextRange _insertionIndex() { + final TextRange selection = value.selection; + final String text = value.text; + return selection.isValid + ? (selection.isCollapsed + ? selection + : TextRange.collapsed(selection.end)) + : TextRange.collapsed(text.length); + } + + /// Tells the controller that a file upload has started. + /// + /// Returns an int "tag" that should be passed to registerUploadEnd on the + /// upload's success or failure. + int registerUploadStart(String filename) { + final tag = _nextUploadTag; + _nextUploadTag += 1; + final placeholder = '[Uploading $filename...]()'; // TODO(i18n) + _uploads[tag] = (filename: filename, placeholder: placeholder); + notifyListeners(); // _uploads change could affect validationErrors + value = value.replaced(_insertionIndex(), '$placeholder\n\n'); + return tag; + } + + /// Tells the controller that a file upload has ended, with success or error. + /// + /// To indicate success, pass the URL to be used for the Markdown link. + /// If `url` is null, failure is assumed. + void registerUploadEnd(int tag, Uri? url) { + final val = _uploads[tag]; + assert(val != null, 'registerUploadEnd called twice for same tag'); + final (:filename, :placeholder) = val!; + final int startIndex = text.indexOf(placeholder); + final replacementRange = startIndex >= 0 + ? TextRange(start: startIndex, end: startIndex + placeholder.length) + : _insertionIndex(); + + value = value.replaced( + replacementRange, + url == null + ? '[Failed to upload file: $filename]()' // TODO(i18n) + : '[$filename](${url.toString()})'); + _uploads.remove(tag); + notifyListeners(); // _uploads change could affect validationErrors + } + String textNormalized() { return text.trim(); } @@ -74,16 +138,24 @@ class ContentTextEditingController extends TextEditingController { // be conservative and may cut the user off shorter than necessary. if (normalized.length > kMaxMessageLengthCodePoints) ContentValidationError.tooLong, + + if (_uploads.isNotEmpty) + ContentValidationError.uploadInProgress, ]; } } /// The content input for StreamComposeBox. class _StreamContentInput extends StatefulWidget { - const _StreamContentInput({required this.controller, required this.topicController}); + const _StreamContentInput({ + required this.controller, + required this.topicController, + required this.focusNode, + }); final ContentTextEditingController controller; final TopicTextEditingController topicController; + final FocusNode focusNode; @override State<_StreamContentInput> createState() => _StreamContentInputState(); @@ -92,7 +164,7 @@ class _StreamContentInput extends StatefulWidget { class _StreamContentInputState extends State<_StreamContentInput> { late String _topicTextNormalized; - _topicValueChanged() { + _topicChanged() { setState(() { _topicTextNormalized = widget.topicController.textNormalized(); }); @@ -102,12 +174,12 @@ class _StreamContentInputState extends State<_StreamContentInput> { void initState() { super.initState(); _topicTextNormalized = widget.topicController.textNormalized(); - widget.topicController.addListener(_topicValueChanged); + widget.topicController.addListener(_topicChanged); } @override void dispose() { - widget.topicController.removeListener(_topicValueChanged); + widget.topicController.removeListener(_topicChanged); super.dispose(); } @@ -126,6 +198,7 @@ class _StreamContentInputState extends State<_StreamContentInput> { ), child: TextField( controller: widget.controller, + focusNode: widget.focusNode, style: TextStyle(color: colorScheme.onSurface), decoration: InputDecoration.collapsed( hintText: "Message #test here > $_topicTextNormalized", @@ -137,6 +210,87 @@ class _StreamContentInputState extends State<_StreamContentInput> { } } +class _AttachFileButton extends StatelessWidget { + const _AttachFileButton({required this.contentController, required this.contentFocusNode}); + + final ContentTextEditingController contentController; + final FocusNode contentFocusNode; + + _handlePress(BuildContext context) async { + FilePickerResult? result; + try { + result = await FilePicker.platform.pickFiles(allowMultiple: true, withReadStream: true); + } catch (e) { + // TODO(i18n) + showErrorDialog(context: context, title: 'Error', message: e.toString()); + return; + } + if (result == null) { + return; // User cancelled; do nothing + } + + if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007 + else { + return; + } + + final store = PerAccountStoreWidget.of(context); + + final List tooLargeFiles = []; + final List rightSizeFiles = []; + for (PlatformFile file in result.files) { + if ((file.size / (1 << 20)) > store.maxFileUploadSizeMib) { + tooLargeFiles.add(file); + } else { + rightSizeFiles.add(file); + } + } + + if (tooLargeFiles.isNotEmpty) { + final listMessage = tooLargeFiles + .map((file) => '${file.name}: ${(file.size / (1 << 20)).toStringAsFixed(1)} MiB') + .join('\n'); + showErrorDialog( // TODO(i18n) + context: context, + title: 'File(s) too large', + message: + '${tooLargeFiles.length} file(s) are larger than the server\'s limit of ${store.maxFileUploadSizeMib} MiB and will not be uploaded:\n\n$listMessage'); + } + + final List<(int, PlatformFile)> uploadsInProgress = []; + for (final file in rightSizeFiles) { + final tag = contentController.registerUploadStart(file.name); + uploadsInProgress.add((tag, file)); + } + if (!contentFocusNode.hasFocus) { + contentFocusNode.requestFocus(); + } + + for (final (tag, file) in uploadsInProgress) { + final PlatformFile(:readStream, :size, :name) = file; + assert(readStream != null); // We passed `withReadStream: true` to pickFiles. + Uri? url; + try { + final result = await uploadFile(store.connection, + content: readStream!, length: size, filename: name); + url = Uri.parse(result.uri); + } catch (e) { + if (!context.mounted) return; + // TODO(#37): Specifically handle `413 Payload Too Large` + // TODO(#37): On API errors, quote `msg` from server, with "The server said:" + showErrorDialog(context: context, + title: 'Failed to upload file: $name', message: e.toString()); + } finally { + contentController.registerUploadEnd(tag, url); + } + } + } + + @override + Widget build(BuildContext context) { + return IconButton(icon: const Icon(Icons.attach_file), onPressed: () => _handlePress(context)); + } +} /// The send button for StreamComposeBox. class _StreamSendButton extends StatefulWidget { @@ -153,7 +307,7 @@ class _StreamSendButtonState extends State<_StreamSendButton> { late List _topicValidationErrors; late List _contentValidationErrors; - _topicValueChanged() { + _topicChanged() { final oldIsEmpty = _topicValidationErrors.isEmpty; final newErrors = widget.topicController.validationErrors(); final newIsEmpty = newErrors.isEmpty; @@ -165,7 +319,7 @@ class _StreamSendButtonState extends State<_StreamSendButton> { } } - _contentValueChanged() { + _contentChanged() { final oldIsEmpty = _contentValidationErrors.isEmpty; final newErrors = widget.contentController.validationErrors(); final newIsEmpty = newErrors.isEmpty; @@ -182,14 +336,14 @@ class _StreamSendButtonState extends State<_StreamSendButton> { super.initState(); _topicValidationErrors = widget.topicController.validationErrors(); _contentValidationErrors = widget.contentController.validationErrors(); - widget.topicController.addListener(_topicValueChanged); - widget.contentController.addListener(_contentValueChanged); + widget.topicController.addListener(_topicChanged); + widget.contentController.addListener(_contentChanged); } @override void dispose() { - widget.topicController.removeListener(_topicValueChanged); - widget.contentController.removeListener(_contentValueChanged); + widget.topicController.removeListener(_topicChanged); + widget.contentController.removeListener(_contentChanged); super.dispose(); } @@ -268,11 +422,13 @@ class StreamComposeBox extends StatefulWidget { class _StreamComposeBoxState extends State { final _topicController = TopicTextEditingController(); final _contentController = ContentTextEditingController(); + final _contentFocusNode = FocusNode(); @override void dispose() { _topicController.dispose(); _contentController.dispose(); + _contentFocusNode.dispose(); super.dispose(); } @@ -310,18 +466,31 @@ class _StreamComposeBoxState extends State { minimum: const EdgeInsets.fromLTRB(8, 0, 8, 8), child: Padding( padding: const EdgeInsets.only(top: 8.0), - child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - Expanded( - child: Theme( - data: inputThemeData, - child: Column( - children: [ - topicInput, - const SizedBox(height: 8), - _StreamContentInput(topicController: _topicController, controller: _contentController), - ]))), - const SizedBox(width: 8), - _StreamSendButton(topicController: _topicController, contentController: _contentController), - ])))); + child: Column( + children: [ + Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ + Expanded( + child: Theme( + data: inputThemeData, + child: Column( + children: [ + topicInput, + const SizedBox(height: 8), + _StreamContentInput( + topicController: _topicController, + controller: _contentController, + focusNode: _contentFocusNode), + ]))), + const SizedBox(width: 8), + _StreamSendButton(topicController: _topicController, contentController: _contentController), + ]), + Theme( + data: themeData.copyWith( + iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)), + child: Row( + children: [ + _AttachFileButton(contentController: _contentController, contentFocusNode: _contentFocusNode), + ])), + ])))); } } diff --git a/pubspec.lock b/pubspec.lock index c88d7b12b1..b5fe0a93c7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -250,6 +250,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.4" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "0d923fb610d0abf67f2149c3a50ef85f78bebecfc4d645719ca70bcf4abc788f" + url: "https://pub.dev" + source: hosted + version: "5.2.7" fixnum: dependency: transitive description: @@ -271,6 +279,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c224ac897bed083dabf11f238dd11a239809b446740be0c2044608c50029ffdf + url: "https://pub.dev" + source: hosted + version: "2.0.9" flutter_test: dependency: "direct dev" description: flutter @@ -405,10 +421,10 @@ packages: dependency: transitive description: name: material_color_utilities - sha256: d92141dc6fe1dad30722f9aa826c7fbc896d021d792f80678280601aff8cf724 + sha256: "586678f20e112219ed0f73215f01bcdf1d769824ba2ebae45ad918a9bfde9bdb" url: "https://pub.dev" source: hosted - version: "0.2.0" + version: "0.3.0" meta: dependency: transitive description: @@ -634,10 +650,10 @@ packages: dependency: transitive description: name: source_span - sha256: dd904f795d4b4f3b870833847c461801f6750a9fa8e61ea5ac53f9422b31f250 + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" url: "https://pub.dev" source: hosted - version: "1.9.1" + version: "1.10.0" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index d8616fd694..0deef45732 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: intl: ^0.18.0 share_plus: ^6.3.1 device_info_plus: ^8.1.0 + file_picker: ^5.2.7 dev_dependencies: flutter_test: diff --git a/test/api/fake_api.dart b/test/api/fake_api.dart index 4fbcd7394a..63d6bd1ccf 100644 --- a/test/api/fake_api.dart +++ b/test/api/fake_api.dart @@ -20,6 +20,11 @@ class FakeApiConnection extends ApiConnection { _nextResponse = response; } + @override + void close() { + // TODO: record connection closed; assert open in methods + } + @override Future get(String route, Map? params) async { final response = _nextResponse; @@ -33,6 +38,13 @@ class FakeApiConnection extends ApiConnection { _nextResponse = null; return response!; } + + @override + Future postFileFromStream(String route, Stream> content, int length, { String? filename }) async { + final response = _nextResponse; + _nextResponse = null; + return response!; + } } const String _fakeApiKey = 'fake-api-key'; diff --git a/test/example_data.dart b/test/example_data.dart index 2c0376aade..15581891f5 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -80,4 +80,5 @@ final InitialSnapshot initialSnapshot = InitialSnapshot( alertWords: ['klaxon'], customProfileFields: [], subscriptions: [], // TODO add subscriptions to example initial snapshot + maxFileUploadSizeMib: 25, );