svg support

This commit is contained in:
Thibault Deckers 2020-03-23 13:00:16 +09:00
parent 38c2207b78
commit 6c8441642c
21 changed files with 218 additions and 95 deletions

View file

@ -217,6 +217,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
// convenience methods
// InputStream.readAllBytes is only available from Java 9+
private byte[] getBytes(InputStream inputStream) throws IOException {
ByteArrayOutputStream byteBuffer = new ByteArrayOutputStream();
int bufferSize = 1024;

View file

@ -34,6 +34,7 @@ import java.util.regex.Pattern;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
@ -69,7 +70,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
}
private boolean isVideo(@Nullable String mimeType) {
return mimeType != null && mimeType.startsWith(Constants.MIME_VIDEO);
return mimeType != null && mimeType.startsWith(MimeTypes.VIDEO);
}
private InputStream getInputStream(String path, String uri) throws FileNotFoundException {
@ -171,7 +172,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler {
Map<String, Object> metadataMap = new HashMap<>();
try (InputStream is = getInputStream(path, uri)) {
if (!Constants.MIME_MP2T.equalsIgnoreCase(mimeType)) {
if (!MimeTypes.MP2T.equals(mimeType)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
// EXIF Sub-IFD

View file

@ -27,8 +27,8 @@ import java.util.HashMap;
import java.util.Map;
import java.util.TimeZone;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import static deckers.thibault.aves.utils.MetadataHelper.getOrientationDegreesForExifCode;
@ -100,11 +100,15 @@ public class ImageEntry {
}
public boolean isImage() {
return mimeType.startsWith(Constants.MIME_IMAGE);
return mimeType.startsWith(MimeTypes.IMAGE);
}
public boolean isSvg() {
return mimeType.equals(MimeTypes.SVG);
}
public boolean isVideo() {
return mimeType.startsWith(Constants.MIME_VIDEO);
return mimeType.startsWith(MimeTypes.VIDEO);
}
// metadata retrieval
@ -179,10 +183,12 @@ public class ImageEntry {
// expects entry with: uri/path, mimeType
// finds: width, height, orientation, date
private void fillByMetadataExtractor(Context context) {
if (MimeTypes.SVG.equals(mimeType)) return;
try (InputStream is = getInputStream(context)) {
Metadata metadata = ImageMetadataReader.readMetadata(is);
if (Constants.MIME_JPEG.equals(mimeType)) {
if (MimeTypes.JPEG.equals(mimeType)) {
JpegDirectory jpegDir = metadata.getFirstDirectoryOfType(JpegDirectory.class);
if (jpegDir != null) {
if (jpegDir.containsTag(JpegDirectory.TAG_IMAGE_WIDTH)) {
@ -201,7 +207,7 @@ public class ImageEntry {
sourceDateTakenMillis = exifDir.getDate(ExifIFD0Directory.TAG_DATETIME, null, TimeZone.getDefault()).getTime();
}
}
} else if (Constants.MIME_MP4.equals(mimeType)) {
} else if (MimeTypes.MP4.equals(mimeType)) {
Mp4VideoDirectory mp4VideoDir = metadata.getFirstDirectoryOfType(Mp4VideoDirectory.class);
if (mp4VideoDir != null) {
if (mp4VideoDir.containsTag(Mp4VideoDirectory.TAG_WIDTH)) {
@ -220,6 +226,8 @@ public class ImageEntry {
// expects entry with: uri/path
// finds: width, height
private void fillByBitmapDecode(Context context) {
if (MimeTypes.SVG.equals(mimeType)) return;
try (InputStream is = getInputStream(context)) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;

View file

@ -15,7 +15,7 @@ class ContentImageProvider extends ImageProvider {
entry.mimeType = mimeType;
entry.fillPreCatalogMetadata(context);
if (entry.hasSize()) {
if (entry.hasSize() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure();

View file

@ -38,7 +38,7 @@ class FileImageProvider extends ImageProvider {
}
entry.fillPreCatalogMetadata(context);
if (entry.hasSize()) {
if (entry.hasSize() || entry.isSvg()) {
callback.onSuccess(entry.toMap());
} else {
callback.onFailure();

View file

@ -33,9 +33,9 @@ import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MetadataHelper;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
@ -144,10 +144,10 @@ public abstract class ImageProvider {
// so we retrieve it again from the file metadata
String metadataMimeType = getMimeType(activity, uri);
switch (metadataMimeType != null ? metadataMimeType : mimeType) {
case Constants.MIME_JPEG:
case MimeTypes.JPEG:
rotateJpeg(activity, path, uri, clockwise, callback);
break;
case Constants.MIME_PNG:
case MimeTypes.PNG:
rotatePng(activity, path, uri, clockwise, callback);
break;
default:
@ -156,7 +156,7 @@ public abstract class ImageProvider {
}
private void rotateJpeg(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
final String mimeType = Constants.MIME_JPEG;
final String mimeType = MimeTypes.JPEG;
String editablePath = path;
boolean onSdCard = Env.isOnSdCard(activity, path);
if (onSdCard) {
@ -232,7 +232,7 @@ public abstract class ImageProvider {
}
private void rotatePng(final Activity activity, final String path, final Uri uri, boolean clockwise, final ImageOpCallback callback) {
final String mimeType = Constants.MIME_PNG;
final String mimeType = MimeTypes.PNG;
if (path == null) {
callback.onFailure();
return;

View file

@ -17,8 +17,8 @@ import java.util.Map;
import java.util.stream.Stream;
import deckers.thibault.aves.model.ImageEntry;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.MimeTypes;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.StorageUtils;
import deckers.thibault.aves.utils.Utils;
@ -71,10 +71,10 @@ public class MediaStoreImageProvider extends ImageProvider {
entry.put("uri", uri.toString());
callback.onSuccess(entry);
};
if (mimeType.startsWith(Constants.MIME_IMAGE)) {
if (mimeType.startsWith(MimeTypes.IMAGE)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(context, onSuccess, contentUri, IMAGE_PROJECTION);
} else if (mimeType.startsWith(Constants.MIME_VIDEO)) {
} else if (mimeType.startsWith(MimeTypes.VIDEO)) {
Uri contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id);
entryCount = fetchFrom(context, onSuccess, contentUri, VIDEO_PROJECTION);
}
@ -114,13 +114,14 @@ public class MediaStoreImageProvider extends ImageProvider {
// this is fine if `contentUri` does not already contain the ID
final Uri itemUri = ContentUris.withAppendedId(contentUri, contentId);
final String path = cursor.getString(pathColumn);
final String mimeType = cursor.getString(mimeTypeColumn);
int width = cursor.getInt(widthColumn);
int height = cursor.getInt(heightColumn);
Map<String, Object> entryMap = new HashMap<String, Object>() {{
put("uri", itemUri.toString());
put("path", path);
put("mimeType", cursor.getString(mimeTypeColumn));
put("mimeType", mimeType);
put("orientationDegrees", orientationColumn != -1 ? cursor.getInt(orientationColumn) : 0);
put("sizeBytes", cursor.getLong(sizeColumn));
put("title", cursor.getString(titleColumn));
@ -134,7 +135,7 @@ public class MediaStoreImageProvider extends ImageProvider {
entryMap.put("width", width);
entryMap.put("height", height);
if (width <= 0 || height <= 0) {
if ((width <= 0 || height <= 0) && !MimeTypes.SVG.equals(mimeType)) {
// some images are incorrectly registered in the Media Store,
// they are valid but miss some attributes, such as width, height, orientation
ImageEntry entry = new ImageEntry(entryMap).fillPreCatalogMetadata(context);
@ -143,9 +144,9 @@ public class MediaStoreImageProvider extends ImageProvider {
height = entry.height != null ? entry.height : 0;
}
if (width <= 0 || height <= 0) {
if ((width <= 0 || height <= 0) && !MimeTypes.SVG.equals(mimeType)) {
// this is probably not a real image, like "/storage/emulated/0", so we skip it
Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path);
Log.w(LOG_TAG, "failed to get size for uri=" + itemUri + ", path=" + path + ", mimeType=" + mimeType);
} else {
newEntryHandler.handleEntry(entryMap);
entryCount++;

View file

@ -8,17 +8,6 @@ import java.util.Map;
public class Constants {
public static final int SD_CARD_PERMISSION_REQUEST_CODE = 1;
// mime types
public static final String MIME_IMAGE = "image";
public static final String MIME_GIF = "image/gif";
public static final String MIME_JPEG = "image/jpeg";
public static final String MIME_PNG = "image/png";
public static final String MIME_VIDEO = "video";
public static final String MIME_MP2T = "video/mp2t"; // .m2ts
public static final String MIME_MP4 = "video/mp4";
// video metadata keys, from android.media.MediaMetadataRetriever
public static final Map<Integer, String> MEDIA_METADATA_KEYS = new HashMap<Integer, String>() {

View file

@ -0,0 +1,13 @@
package deckers.thibault.aves.utils;
public class MimeTypes {
public static final String IMAGE = "image";
public static final String GIF = "image/gif";
public static final String JPEG = "image/jpeg";
public static final String PNG = "image/png";
public static final String SVG = "image/svg+xml";
public static final String VIDEO = "video";
public static final String MP2T = "video/mp2t"; // .m2ts
public static final String MP4 = "video/mp4";
}

View file

@ -99,6 +99,8 @@ class ImageEntry {
bool get isGif => mimeType == MimeTypes.MIME_GIF;
bool get isSvg => mimeType == MimeTypes.MIME_SVG;
bool get isVideo => mimeType.startsWith(MimeTypes.MIME_VIDEO);
bool get isCatalogued => catalogMetadata != null;

View file

@ -8,6 +8,8 @@ class MetadataService {
// return Map<Map<Key, Value>> (map of directories, each directory being a map of metadata label and value description)
static Future<Map> getAllMetadata(ImageEntry entry) async {
if (entry.isSvg) return null;
try {
final result = await platform.invokeMethod('getAllMetadata', <String, dynamic>{
'mimeType': entry.mimeType,
@ -22,6 +24,8 @@ class MetadataService {
}
static Future<CatalogMetadata> getCatalogMetadata(ImageEntry entry) async {
if (entry.isSvg) return null;
try {
// return map with:
// 'dateMillis': date taken in milliseconds since Epoch (long)
@ -42,6 +46,8 @@ class MetadataService {
}
static Future<OverlayMetadata> getOverlayMetadata(ImageEntry entry) async {
if (entry.isSvg) return null;
try {
// return map with string descriptions for: 'aperture' 'exposureTime' 'focalLength' 'iso'
final result = await platform.invokeMethod('getOverlayMetadata', <String, dynamic>{

View file

@ -1,6 +1,7 @@
class MimeTypes {
static const String MIME_VIDEO = 'video';
static const String MIME_GIF = 'image/gif';
static const String MIME_JPEG = 'image/jpeg';
static const String MIME_PNG = 'image/png';
static const String MIME_GIF = 'image/gif';
static const String MIME_SVG = 'image/svg+xml';
static const String MIME_VIDEO = 'video';
}

View file

@ -1,7 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
class Constants {
// as of Flutter v1.11.0, overflowing `Text` miscalculates height and some text (e.g. 'Å') is clipped
// so we give it a `strutStyle` with a slightly larger height
static const overflowStrutStyle = StrutStyle(height: 1.3);
static const svgBackground = Colors.white;
static const svgColorFilter = ColorFilter.mode(svgBackground, BlendMode.dstOver);
}

View file

@ -1,9 +1,12 @@
import 'dart:math';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/image_preview.dart';
import 'package:aves/widgets/fullscreen/uri_picture_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class Thumbnail extends StatelessWidget {
final ImageEntry entry;
@ -22,44 +25,6 @@ class Thumbnail extends StatelessWidget {
@override
Widget build(BuildContext context) {
final image = ImagePreview(
entry: entry,
// TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling
width: 50,
height: 50,
builder: (bytes) {
final image = Image.memory(
bytes,
width: extent,
height: extent,
fit: BoxFit.cover,
);
return heroTag == null
? image
: Hero(
tag: heroTag,
flightShuttleBuilder: (
BuildContext flightContext,
Animation<double> animation,
HeroFlightDirection flightDirection,
BuildContext fromHeroContext,
BuildContext toHeroContext,
) {
// use LayoutBuilder only during hero animation
return LayoutBuilder(builder: (context, constraints) {
final dim = min(constraints.maxWidth, constraints.maxHeight);
return Image.memory(
bytes,
width: dim,
height: dim,
fit: BoxFit.cover,
);
});
},
child: image,
);
},
);
return Container(
decoration: BoxDecoration(
border: Border.all(
@ -72,7 +37,7 @@ class Thumbnail extends StatelessWidget {
child: Stack(
alignment: AlignmentDirectional.bottomStart,
children: [
image,
entry.isSvg ? _buildVectorImage() : _buildRasterImage(),
_ThumbnailOverlay(
entry: entry,
extent: extent,
@ -81,6 +46,59 @@ class Thumbnail extends StatelessWidget {
),
);
}
Widget _buildRasterImage() {
return ImagePreview(
entry: entry,
// TODO TLAD smarter sizing, but shouldn't only depend on `extent` so that it doesn't reload during gridview scaling
width: 50,
height: 50,
builder: (bytes) {
final imageBuilder = (bytes, dim) => Image.memory(
bytes,
width: dim,
height: dim,
fit: BoxFit.cover,
);
return heroTag == null
? imageBuilder(bytes, extent)
: Hero(
tag: heroTag,
flightShuttleBuilder: (flightContext, animation, flightDirection, fromHeroContext, toHeroContext) {
// use LayoutBuilder only during hero animation
return LayoutBuilder(builder: (context, constraints) {
final dim = min(constraints.maxWidth, constraints.maxHeight);
return imageBuilder(bytes, dim);
});
},
child: imageBuilder(bytes, extent),
);
},
);
}
Widget _buildVectorImage() {
final child = Container(
// center `SvgPicture` inside `Container` with the thumbnail dimensions
// so that `SvgPicture` doesn't get aligned by the `Stack` like the overlay icons
width: extent,
height: extent,
child: SvgPicture(
UriPicture(
entry.uri,
colorFilter: Constants.svgColorFilter,
),
width: extent,
height: extent,
),
);
return heroTag == null
? child
: Hero(
tag: heroTag,
child: child,
);
}
}
class _ThumbnailOverlay extends StatelessWidget {

View file

@ -36,7 +36,7 @@ class ImagePreviewState extends State<ImagePreview> with AfterInitMixin {
@override
void initState() {
debugPrint('$runtimeType initState path=${entry.path}');
// debugPrint('$runtimeType initState path=${entry.path}');
super.initState();
_entryChangeNotifier = Listenable.merge([
entry.imageChangeNotifier,
@ -55,14 +55,14 @@ class ImagePreviewState extends State<ImagePreview> with AfterInitMixin {
void didUpdateWidget(ImagePreview old) {
// debugPrint('$runtimeType didUpdateWidget from=${old.entry.path} to=${entry.path}');
super.didUpdateWidget(old);
if (widget.width == old.width && widget.height == old.height && uri == old.entry.uri && widget.entry.width == old.entry.width && widget.entry.height == old.entry.height && widget.entry.orientationDegrees == old.entry.orientationDegrees) return;
if (widget.width == old.width && widget.height == old.height && uri == old.entry.uri && entry.width == old.entry.width && entry.height == old.entry.height && entry.orientationDegrees == old.entry.orientationDegrees) return;
_initByteLoader();
}
void _initByteLoader() {
final width = (widget.width * _devicePixelRatio).round();
final height = (widget.height * _devicePixelRatio).round();
_byteLoader = ImageFileService.getImageBytes(widget.entry, width, height);
_byteLoader = ImageFileService.getImageBytes(entry, width, height);
}
void _onEntryChange() => setState(() => _initByteLoader());

View file

@ -5,7 +5,7 @@ import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/fullscreen_action_delegate.dart';
import 'package:aves/widgets/fullscreen/image_page.dart';
import 'package:aves/widgets/fullscreen/image_uri.dart';
import 'package:aves/widgets/fullscreen/uri_image_provider.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';
import 'package:aves/widgets/fullscreen/overlay/bottom.dart';
import 'package:aves/widgets/fullscreen/overlay/top.dart';

View file

@ -1,7 +1,10 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/widgets/fullscreen/image_uri.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/widgets/fullscreen/uri_image_provider.dart';
import 'package:aves/widgets/fullscreen/uri_picture_provider.dart';
import 'package:aves/widgets/fullscreen/video.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:photo_view/photo_view.dart';
import 'package:tuple/tuple.dart';
import 'package:video_player/video_player.dart';
@ -43,26 +46,47 @@ class ImageView extends StatelessWidget {
);
}
final placeholderBuilder = (context) => const Center(
child: SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
);
final heroAttributes = heroTag != null
? PhotoViewHeroAttributes(
tag: heroTag,
transitionOnUserGestures: true,
)
: null;
if (entry.isSvg) {
return PhotoView.customChild(
child: SvgPicture(
UriPicture(
entry.uri,
colorFilter: Constants.svgColorFilter,
),
placeholderBuilder: placeholderBuilder,
),
backgroundDecoration: backgroundDecoration,
heroAttributes: heroAttributes,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,
onTapUp: (tapContext, details, value) => onTap?.call(),
);
}
return PhotoView(
// key includes size and orientation to refresh when the image is rotated
key: ValueKey('${entry.orientationDegrees}_${entry.width}_${entry.height}_${entry.path}'),
imageProvider: UriImage(entry.uri),
loadingBuilder: (context, event) => const Center(
child: SizedBox(
width: 64,
height: 64,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
loadingBuilder: (context, event) => placeholderBuilder(context),
backgroundDecoration: backgroundDecoration,
heroAttributes: heroTag != null
? PhotoViewHeroAttributes(
tag: heroTag,
transitionOnUserGestures: true,
)
: null,
heroAttributes: heroAttributes,
scaleStateChangedCallback: onScaleChanged,
minScale: PhotoViewComputedScale.contained,
initialScale: PhotoViewComputedScale.contained,

View file

@ -23,7 +23,7 @@ class BasicSection extends StatelessWidget {
'Title': entry.title ?? '?',
'Date': dateText,
if (entry.isVideo) ..._buildVideoRows(),
'Resolution': resolutionText,
if (!entry.isSvg) 'Resolution': resolutionText,
'Size': entry.sizeBytes != null ? formatFilesize(entry.sizeBytes) : '?',
'URI': entry.uri ?? '?',
if (entry.path != null) 'Path': entry.path,

View file

@ -237,7 +237,7 @@ class _DateRow extends StatelessWidget {
const Icon(OMIcons.calendarToday, size: _iconSize),
const SizedBox(width: _iconPadding),
Expanded(flex: 3, child: Text(dateText, strutStyle: Constants.overflowStrutStyle)),
Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)),
if (!entry.isSvg) Expanded(flex: 2, child: Text(resolution, strutStyle: Constants.overflowStrutStyle)),
],
);
}

View file

@ -0,0 +1,55 @@
import 'package:aves/model/image_file_service.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:pedantic/pedantic.dart';
class UriPicture extends PictureProvider<UriPicture> {
const UriPicture(this.uri, {this.colorFilter}) : assert(uri != null);
final String uri;
/// The [ColorFilter], if any, to use when drawing this picture.
final ColorFilter colorFilter;
@override
Future<UriPicture> obtainKey(PictureConfiguration configuration) {
return SynchronousFuture<UriPicture>(this);
}
@override
PictureStreamCompleter load(UriPicture key, {PictureErrorListener onError}) {
return OneFramePictureStreamCompleter(_loadAsync(key, onError: onError), informationCollector: () sync* {
yield DiagnosticsProperty<String>('Uri', uri);
});
}
Future<PictureInfo> _loadAsync(UriPicture key, {PictureErrorListener onError}) async {
assert(key == this);
final data = await ImageFileService.readAsBytes(uri);
if (data == null || data.isEmpty) {
return null;
}
final decoder = SvgPicture.svgByteDecoder;
if (onError != null) {
final future = decoder(data, colorFilter, key.toString());
unawaited(future.catchError(onError));
return future;
}
return decoder(data, colorFilter, key.toString());
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is UriPicture && other.uri == uri && other.colorFilter == colorFilter;
}
@override
int get hashCode => hashValues(uri, colorFilter);
@override
String toString() => '${objectRuntimeType(this, 'UriPicture')}("$uri", colorFilter: $colorFilter)';
}