Skip to content

compose: Prototype upload-from-library and upload-from-camera #70

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 28, 2023
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
12 changes: 12 additions & 0 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
PODS:
- "app_settings (3.0.0+1)":
- Flutter
- device_info_plus (0.0.1):
- Flutter
- DKImagePickerController/Core (4.3.4):
Expand Down Expand Up @@ -36,6 +38,8 @@ PODS:
- DKImagePickerController/PhotoGallery
- Flutter
- Flutter (1.0.0)
- image_picker_ios (0.0.1):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
Expand All @@ -62,9 +66,11 @@ PODS:
- SwiftyGif (5.4.4)

DEPENDENCIES:
- app_settings (from `.symlinks/plugins/app_settings/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- Flutter (from `Flutter`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
Expand All @@ -78,12 +84,16 @@ SPEC REPOS:
- SwiftyGif

EXTERNAL SOURCES:
app_settings:
:path: ".symlinks/plugins/app_settings/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
Flutter:
:path: Flutter
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
share_plus:
Expand All @@ -92,11 +102,13 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/sqlite3_flutter_libs/ios"

SPEC CHECKSUMS:
app_settings: d103828c9f5d515c4df9ee754dabd443f7cedcf3
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: ce3938a0df3cc1ef404671531facef740d03f920
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
path_provider_foundation: c68054786f1b4f3343858c1e1d0caaded73f0be9
SDWebImage: fd7e1a22f00303e058058278639bf6196ee431fe
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
Expand Down
4 changes: 4 additions & 0 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,9 @@
<array>
<string>fetch</string>
</array>
<key>NSCameraUsageDescription</key>
<string>By allowing camera access, you can take photos and send them in Zulip messages.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Choose photos from your library and send them in Zulip messages.</string>
</dict>
</plist>
259 changes: 200 additions & 59 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import 'package:app_settings/app_settings.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:image_picker/image_picker.dart';
import 'dialog.dart';

import '../api/route/messages.dart';
Expand Down Expand Up @@ -210,85 +213,221 @@ class _StreamContentInputState extends State<_StreamContentInput> {
}
}

class _AttachFileButton extends StatelessWidget {
const _AttachFileButton({required this.contentController, required this.contentFocusNode});
/// Data on a file to be uploaded, from any source.
///
/// A convenience class to represent data from the generic file picker,
/// the media library, and the camera, in a single form.
class _File {
_File({required this.content, required this.length, required this.filename});

final ContentTextEditingController contentController;
final FocusNode contentFocusNode;
final Stream<List<int>> content;
final int length;
final String filename;
}

Future<void> _uploadFiles({
required BuildContext context,
required ContentTextEditingController contentController,
required FocusNode contentFocusNode,
required Iterable<_File> files,
}) async {
assert(context.mounted);
final store = PerAccountStoreWidget.of(context);

final List<_File> tooLargeFiles = [];
final List<_File> rightSizeFiles = [];
for (final file in files) {
if ((file.length / (1 << 20)) > store.maxFileUploadSizeMib) {
tooLargeFiles.add(file);
} else {
rightSizeFiles.add(file);
}
}

if (tooLargeFiles.isNotEmpty) {
final listMessage = tooLargeFiles
.map((file) => '${file.filename}: ${(file.length / (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, _File)> uploadsInProgress = [];
for (final file in rightSizeFiles) {
final tag = contentController.registerUploadStart(file.filename);
uploadsInProgress.add((tag, file));
}
if (!contentFocusNode.hasFocus) {
contentFocusNode.requestFocus();
}

_handlePress(BuildContext context) async {
FilePickerResult? result;
for (final (tag, file) in uploadsInProgress) {
final _File(:content, :length, :filename) = file;
Uri? url;
try {
result = await FilePicker.platform.pickFiles(allowMultiple: true, withReadStream: true);
final result = await uploadFile(store.connection,
content: content, length: length, filename: filename);
url = Uri.parse(result.uri);
} catch (e) {
// TODO(i18n)
showErrorDialog(context: context, title: 'Error', message: e.toString());
return;
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: $filename', message: e.toString());
} finally {
contentController.registerUploadEnd(tag, url);
}
if (result == null) {
return; // User cancelled; do nothing
}
}

abstract class _AttachUploadsButton extends StatelessWidget {
const _AttachUploadsButton({required this.contentController, required this.contentFocusNode});

final ContentTextEditingController contentController;
final FocusNode contentFocusNode;

IconData get icon;

/// Request files from the user, in the way specific to this upload type.
///
/// Subclasses should manage the interaction completely, e.g., by catching and
/// handling any permissions-related exceptions.
///
/// To signal exiting the interaction with no files chosen,
/// return an empty [Iterable] after showing user feedback as appropriate.
Future<Iterable<_File>> getFiles(BuildContext context);

void _handlePress(BuildContext context) async {
final files = await getFiles(context);
if (files.isEmpty) {
return; // Nothing to do (getFiles handles user feedback)
}

if (context.mounted) {} // https://github.com/dart-lang/linter/issues/4007
else {
return;
}

final store = PerAccountStoreWidget.of(context);
await _uploadFiles(
context: context,
contentController: contentController,
contentFocusNode: contentFocusNode,
files: files);
}

final List<PlatformFile> tooLargeFiles = [];
final List<PlatformFile> rightSizeFiles = [];
for (PlatformFile file in result.files) {
if ((file.size / (1 << 20)) > store.maxFileUploadSizeMib) {
tooLargeFiles.add(file);
} else {
rightSizeFiles.add(file);
}
}
@override
Widget build(BuildContext context) {
return IconButton(icon: Icon(icon), onPressed: () => _handlePress(context));
}
}

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');
Future<Iterable<_File>> _getFilePickerFiles(BuildContext context, FileType type) async {
FilePickerResult? result;
try {
result = await FilePicker.platform
.pickFiles(allowMultiple: true, withReadStream: true, type: type);
} catch (e) {
if (e is PlatformException && e.code == 'read_external_storage_denied') {
// Observed on Android. If Android's error message tells us whether the
// user has checked "Don't ask again", it seems the library doesn't pass
// that on to us. So just always prompt to check permissions in settings.
// If the user hasn't checked "Don't ask again", they can always dismiss
// our prompt and retry, and the permissions request will reappear,
// letting them grant permissions and complete the upload.
showSuggestedActionDialog(context: context, // TODO(i18n)
title: 'Permissions needed',
message: 'To upload files, please grant Zulip additional permissions in Settings.',
actionButtonText: 'Open settings',
onActionButtonPress: () {
AppSettings.openAppSettings();
});
} else {
// TODO(i18n)
showErrorDialog(context: context, title: 'Error', message: e.toString());
}
return [];
}
if (result == null) {
return []; // User cancelled; do nothing
}

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();
}
return result.files.map((f) {
assert(f.readStream != null); // We passed `withReadStream: true` to pickFiles.
return _File(content: f.readStream!, length: f.size, filename: f.name);
});
}

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);
}
}
class _AttachFileButton extends _AttachUploadsButton {
const _AttachFileButton({required super.contentController, required super.contentFocusNode});

@override
IconData get icon => Icons.attach_file;

@override
Future<Iterable<_File>> getFiles(BuildContext context) async {
return _getFilePickerFiles(context, FileType.any);
}
}

class _AttachMediaButton extends _AttachUploadsButton {
const _AttachMediaButton({required super.contentController, required super.contentFocusNode});

@override
Widget build(BuildContext context) {
return IconButton(icon: const Icon(Icons.attach_file), onPressed: () => _handlePress(context));
IconData get icon => Icons.image;

@override
Future<Iterable<_File>> getFiles(BuildContext context) async {
// TODO: This doesn't give quite the right UI on Android.
// Perhaps try `image_picker`: https://github.com/zulip/zulip-flutter/issues/56#issuecomment-1514001281
return _getFilePickerFiles(context, FileType.media);
}
}

class _AttachFromCameraButton extends _AttachUploadsButton {
const _AttachFromCameraButton({required super.contentController, required super.contentFocusNode});

@override
IconData get icon => Icons.camera_alt;

@override
Future<Iterable<_File>> getFiles(BuildContext context) async {
final picker = ImagePicker();
final XFile? result;
try {
// Ideally we'd open a platform interface that lets you choose between
// taking a photo and a video. `image_picker` doesn't yet have that
// option: https://github.com/flutter/flutter/issues/89159
// so just stick with images for now. We could add another button for
// videos, but we don't want too many buttons.
result = await picker.pickImage(source: ImageSource.camera, requestFullMetadata: false);
} catch (e) {
if (e is PlatformException && e.code == 'camera_access_denied') {
Comment on lines +406 to +407
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Could one imagine a nice language feature that lets you match on more than just the type of the thing being thrown? The following is already possible:

} on PlatformException catch (e) {

But I'm thinking like, I dunno,

} on PlatformException(code: var c) when c == 'camera_access_denied' catch (e) {

or something?

This may be relevant: dart-lang/language#112 (comment)

Copy link
Member

Choose a reason for hiding this comment

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

This may be relevant: dart-lang/language#112 (comment)

Yeah. Looks like the Dart folks have indeed imagined that feature 🙂 Perhaps it'll get added at some point.

// iOS has a quirk where it will only request the native
// permission-request alert once, the first time the app wants to
// use a protected resource. After that, the only way the user can
// grant it is in Settings.
showSuggestedActionDialog(context: context, // TODO(i18n)
title: 'Permissions needed',
message: 'To upload an image, please grant Zulip additional permissions in Settings.',
actionButtonText: 'Open settings',
onActionButtonPress: () {
AppSettings.openAppSettings();
Copy link
Member

Choose a reason for hiding this comment

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

Looks like this is from a third-party library: https://pub.dev/packages/app_settings

Have you tried this out on both iOS and Android and it seems to work?

If at any point we're dissatisfied with how it works, the implementation is pretty simple — especially on iOS, where all the AppSettings.openFoo methods do the same thing:
https://github.com/spencerccf/app_settings/blob/ed65816d72933c9110dff9f4a74ee550e2af20b6/ios/Classes/SwiftAppSettingsPlugin.swift#L7-L10
but the Android version isn't complicated either:
https://github.com/spencerccf/app_settings/blob/ed65816d72933c9110dff9f4a74ee550e2af20b6/android/src/main/kotlin/com/example/appsettings/AppSettingsPlugin.kt#L45-L51

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

AppSettings.openAppSettings() worked great on my iPhone 13 Pro running iOS 16.

Android office device (Samsung Galaxy S9, Android 9) seemed OK, but I wonder: have you ever had an app link directly into the screen for managing an app's permissions? This one brought me here:

and then I scrolled down and found a link to permissions:

which I tapped and found what I needed:

Copy link
Member

Choose a reason for hiding this comment

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

This one brought me here:

Same result when I try that function on my Pixel 5 running Android 13. Except that the subtitle on "Permissions" is "No permissions requested", because we're successfully using the no-privileges-needed API for this on newer Android versions. (To even call this function, I hacked up the code a bit.)

I wonder: have you ever had an app link directly into the screen for managing an app's permissions?

Not sure. It's pretty uncommon for me to encounter an app linking me to any part of its system settings.

(Which doesn't mean we shouldn't do it; for one thing, this is an uncommon case, so it's quite possible that lots of apps offer the same thing if you do get into this case.)

Looking at docs, here's the intent type that the library is using for us:
https://developer.android.com/reference/android/provider/Settings#ACTION_APPLICATION_DETAILS_SETTINGS

Browsing around that page, here's a related intent type:
https://developer.android.com/reference/android/provider/Settings#ACTION_STORAGE_VOLUME_ACCESS_SETTINGS
which was deprecated, saying that "to manage storage permissions for a specific application" we should instead use… ACTION_APPLICATION_DETAILS_SETTINGS, just as app_settings is using for us.

There are various other intent types for particular permissions, including some storage-related:
https://developer.android.com/reference/android/provider/Settings#ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
https://developer.android.com/reference/android/provider/Settings#ACTION_REQUEST_MANAGE_MEDIA

I don't think either of those correspond to what we'd want here, though. In particular both of those were introduced only in recent API versions, and I think they correspond to the newer more-explicitly-powerful permissions that were introduced at the same time as making normal applications stop needing such permissions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

That reasoning makes sense.

});
} else {
// TODO(i18n)
showErrorDialog(context: context, title: 'Error', message: e.toString());
}
return [];
}
if (result == null) {
return []; // User cancelled; do nothing
}
final length = await result.length();

return [_File(content: result.openRead(), length: length, filename: result.name)];
}
}

Expand Down Expand Up @@ -490,6 +629,8 @@ class _StreamComposeBoxState extends State<StreamComposeBox> {
child: Row(
children: [
_AttachFileButton(contentController: _contentController, contentFocusNode: _contentFocusNode),
_AttachMediaButton(contentController: _contentController, contentFocusNode: _contentFocusNode),
_AttachFromCameraButton(contentController: _contentController, contentFocusNode: _contentFocusNode),
])),
]))));
}
Expand Down
20 changes: 20 additions & 0 deletions lib/widgets/dialog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,23 @@ void showErrorDialog({required BuildContext context, required String title, Stri
child: _dialogActionText('OK')),
]));
}

void showSuggestedActionDialog({
required BuildContext context,
required String title,
required String message,
required String? actionButtonText,
required VoidCallback onActionButtonPress,
}) {
showDialog(context: context, builder: (BuildContext context) => AlertDialog(
title: Text(title),
content: SingleChildScrollView(child: Text(message)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: _dialogActionText('Cancel')),
TextButton(
onPressed: onActionButtonPress,
child: _dialogActionText(actionButtonText ?? 'Continue')),
]));
}
Loading