Skip to content
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

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

Merged
merged 11 commits into from
Apr 28, 2023

Conversation

chrisbobbe
Copy link
Collaborator

@chrisbobbe chrisbobbe commented Apr 19, 2023

Marking as draft while we think through how to get the right photo/video-picker UI on Android (details at #56 (comment) ) but the code is reviewable in the meantime, and people can try out the new features. 🙂

Fixes: #61
Fixes: #56

@chrisbobbe chrisbobbe requested a review from gnprice April 19, 2023 01:18
@chrisbobbe chrisbobbe marked this pull request as draft April 19, 2023 01:19
Comment on lines +378 to +407
} catch (e) {
if (e is PlatformException && e.code == 'camera_access_denied') {
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.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Thanks! This looks good. Small comments below.

assert(f.readStream != null); // We passed `withReadStream: true` to pickFiles.
return _File(content: f.readStream!, length: f.size, filename: f.name);
});
_uploadFiles(context: context, contentController: contentController, contentFocusNode: contentFocusNode,
Copy link
Member

Choose a reason for hiding this comment

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

should await — it doesn't directly matter for the behavior, but if e.g. we added some more code after this call, it'd be buggy if we didn't then notice we needed to add an await. And it's a lot easier to notice the need for it now while we're factoring this out.

onPressed: onActionButtonPress,
child: _dialogActionText(actionButtonText ?? 'Continue')),
]));
}
Copy link
Member

Choose a reason for hiding this comment

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

nit: last line should end with newline, like other lines

Comment on lines +378 to +407
} catch (e) {
if (e is PlatformException && e.code == 'camera_access_denied') {
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.

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.

final String filename;
}

_uploadFiles({
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
_uploadFiles({
void _uploadFiles({

Otherwise its return type is dynamic.

(There's probably a lint for this, which we should enable. If we ever do want a function returning dynamic, or a variable of type dynamic, etc., I'd want to say so explicitly.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Future<void>, I think, since it's async?

Copy link
Member

Choose a reason for hiding this comment

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

If we're going to await the result, then definitely Future<void> instead. To explicitly say it's not the caller's business whether this function completes synchronously or does some async work, one can say void.

The docs on this are very terse: https://dart.dev/language/built-in-types

Better description here, perhaps: https://www.reddit.com/r/dartlang/comments/oo65yd/does_void_mean_return_null_in_dart_or_simply_just/

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Ah OK, that makes sense. Since you pointed out that we should indeed await the result, in #70 (comment), I plan to go with Future<void>.

final ContentTextEditingController contentController;
final FocusNode contentFocusNode;

_handlePress(BuildContext context) async {
Copy link
Member

Choose a reason for hiding this comment

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

Similarly

Suggested change
_handlePress(BuildContext context) async {
dynamic _handlePress(BuildContext context) async {

and I guess I missed that previously on _AttachFileButton.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, I don't understand this one yet, and what are you referring to with "similarly"? Do you mean Future<void>, or should it really be dynamic?

Copy link
Member

Choose a reason for hiding this comment

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

By "similarly" I mean to not have an implicit dynamic.

But probably we don't actually want dynamic, and void is better.

class _AttachFromCameraButton extends StatelessWidget {
_AttachFromCameraButton({required this.contentController, required this.contentFocusNode});

final _picker = ImagePicker();
Copy link
Member

Choose a reason for hiding this comment

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

It makes me a bit nervous to be constructing this in an initializer on a widget.

I think the right fix is that we should just construct it in _handlePress. If there were some way that could go wrong on account of constructing it multiple times, then we'd be exposed to the same problem from constructing it here — because we'll be constructing new _AttachFromCameraButton widgets each time StreamComposeBox rebuilds.

In fact, from the implementation, it looks like ImagePicker has no non-static properties at all — its methods (except for == and hashCode inherited from Object) all behave exactly the same on any instance, as if they were static methods. That means that where and when we construct it really doesn't matter.

So probably the methods should be static methods; or the class should have a const constructor, which would accomplish much the same thing (and be a smoother migration from the status quo). But because they're not and it doesn't, the API isn't promising that we have that flexibility. I'd therefore like to avoid the pattern that looks like a bug and makes me nervous 🙂

Comment on lines 329 to 348
try {
result = await FilePicker.platform.pickFiles(type: FileType.media, 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 Iterable<_File> files = 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);
});
Copy link
Member

Choose a reason for hiding this comment

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

Kind of unsatisfying how much of this code gets duplicated between the three buttons, and especially the two that are using FilePicker.

I think it might improve things to factor much of this into something like

abstract class _AttachUploadsButton extends StatelessWidget {
  // …
  Icon get icon;
  Future<Iterable<_File>?> getFiles(BuildContext context);
  // …
}

and then _AttachMediaButton and the others would be subclasses just implementing those two items. Those implementations would take care of error dialogs and settings requests.

chrisbobbe added a commit to chrisbobbe/zulip-flutter that referenced this pull request Apr 22, 2023
The other upload buttons will extend this new base class too.
Prompted by Greg's review:
  zulip#70 (comment)
@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed, with a TODO about the better image-library upload experience on Android, and so marking as not-draft.

In this revision I added a commit to improve the existing upload-file UI:

compose: Handle a permissions-denial case in attach-file UI

and applied that same improvement to image-library uploads (by reusing code) since that also uses file_picker for now.

@chrisbobbe
Copy link
Collaborator Author

And please see my reply about AppSettings.openAppSettings() above: #70 (comment)

@chrisbobbe chrisbobbe marked this pull request as ready for review April 25, 2023 22:47
@chrisbobbe
Copy link
Collaborator Author

and so marking as not-draft.

(Er, now I've done that.)

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

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

Thanks! Generally looks good; one substantive question, about the attach-image behavior.

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);
Copy link
Member

Choose a reason for hiding this comment

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

It looks like this ends up identical to the attach-file button.

In the previous draft, this passed type: FileType.media to FilePicker.platform.pickFiles. Was it intentional to drop that?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Eep, thanks for the catch; I seem to have dropped that difference when refactoring to pull common logic out.

Comment on lines 403 to 404
showSuggestedActionDialog(context: context, // TODO(i18n)
title: 'Permissions needed',
Copy link
Member

Choose a reason for hiding this comment

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

nit: indentation

final FocusNode contentFocusNode;

IconData get icon;
Future<Iterable<_File>?> getFiles(BuildContext context);
Copy link
Member

Choose a reason for hiding this comment

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

It'd be good to have a bit of dartdoc, particularly to say what null means.

… Hmm, actually, perhaps better: make it non-nullable, and just return an empty list in that case. Then at the call site, skip doing anything if the list is empty (rather than if null).

That will even behave differently from this version if there's some way that pickFiles or whatever can return an empty list: it'll do nothing in that case, whereas this version would go and focus the content input. And I think doing nothing is appropriate — if it does return an empty list, that probably means the user has effectively tried to abort the operation.

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.

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.

@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed.

@gnprice
Copy link
Member

gnprice commented Apr 28, 2023

Thanks! Looks good; merging.

@gnprice gnprice merged commit f99a3b3 into zulip:main Apr 28, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Open camera to take and share a photo Add images from compose box
2 participants