diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java index 90e06dc34..9217946bf 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/calls/MetadataHandler.java @@ -12,6 +12,7 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.exifinterface.media.ExifInterface; import com.adobe.internal.xmp.XMPException; import com.adobe.internal.xmp.XMPIterator; @@ -190,6 +191,9 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { case "getContentResolverMetadata": new Thread(() -> getContentResolverMetadata(call, new MethodResultWrapper(result))).start(); break; + case "getExifInterfaceMetadata": + new Thread(() -> getExifInterfaceMetadata(call, new MethodResultWrapper(result))).start(); + break; case "getEmbeddedPictures": new Thread(() -> getEmbeddedPictures(call, new MethodResultWrapper(result))).start(); break; @@ -533,6 +537,27 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { } } + private void getExifInterfaceMetadata(MethodCall call, MethodChannel.Result result) { + String uriString = call.argument("uri"); + if (uriString == null) { + result.error("getExifInterfaceMetadata-args", "failed because of missing arguments", null); + return; + } + + try (InputStream is = StorageUtils.openInputStream(context, Uri.parse(uriString))) { + ExifInterface exif = new ExifInterface(is); + Map metadataMap = new HashMap<>(); + for (String tag : MetadataHelper.ExifInterfaceTags) { + if (exif.hasAttribute(tag)) { + metadataMap.put(tag, exif.getAttribute(tag)); + } + } + result.success(metadataMap); + } catch (IOException e) { + result.error("getExifInterfaceMetadata-failure", "failed to get exif for uri=" + uriString, e.getMessage()); + } + } + private void getEmbeddedPictures(@NonNull MethodCall call, @NonNull MethodChannel.Result result) { Uri uri = Uri.parse(call.argument("uri")); List pictures = new ArrayList<>(); @@ -544,7 +569,7 @@ public class MetadataHandler implements MethodChannel.MethodCallHandler { pictures.add(picture); } } catch (Exception e) { - result.error("getVideoEmbeddedPictures-failure", "failed to get embedded picture for uri=" + uri, e); + result.error("getVideoEmbeddedPictures-failure", "failed to get embedded picture for uri=" + uri, e.getMessage()); } finally { // cannot rely on `MediaMetadataRetriever` being `AutoCloseable` on older APIs retriever.release(); diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataHelper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataHelper.kt index 2c5d2ebb0..b5562a921 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataHelper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MetadataHelper.kt @@ -1,20 +1,183 @@ package deckers.thibault.aves.utils import androidx.exifinterface.media.ExifInterface -import java.text.DateFormat import java.text.ParseException import java.text.SimpleDateFormat import java.util.* import java.util.regex.Pattern object MetadataHelper { + // list of known tags from ExifInterface as of androidx.exifinterface:exifinterface:1.3.0 + @JvmField + val ExifInterfaceTags = arrayOf( + ExifInterface.TAG_APERTURE_VALUE, + ExifInterface.TAG_ARTIST, + ExifInterface.TAG_BITS_PER_SAMPLE, + ExifInterface.TAG_BODY_SERIAL_NUMBER, + ExifInterface.TAG_BRIGHTNESS_VALUE, + ExifInterface.TAG_CAMERA_OWNER_NAME, + ExifInterface.TAG_CFA_PATTERN, + ExifInterface.TAG_COLOR_SPACE, + ExifInterface.TAG_COMPONENTS_CONFIGURATION, + ExifInterface.TAG_COMPRESSED_BITS_PER_PIXEL, + ExifInterface.TAG_COMPRESSION, + ExifInterface.TAG_CONTRAST, + ExifInterface.TAG_COPYRIGHT, + ExifInterface.TAG_CUSTOM_RENDERED, + ExifInterface.TAG_DATETIME, + ExifInterface.TAG_DATETIME_DIGITIZED, + ExifInterface.TAG_DATETIME_ORIGINAL, + ExifInterface.TAG_DEFAULT_CROP_SIZE, + ExifInterface.TAG_DEVICE_SETTING_DESCRIPTION, + ExifInterface.TAG_DIGITAL_ZOOM_RATIO, + ExifInterface.TAG_DNG_VERSION, + ExifInterface.TAG_EXIF_VERSION, + ExifInterface.TAG_EXPOSURE_BIAS_VALUE, + ExifInterface.TAG_EXPOSURE_INDEX, + ExifInterface.TAG_EXPOSURE_MODE, + ExifInterface.TAG_EXPOSURE_PROGRAM, + ExifInterface.TAG_EXPOSURE_TIME, + ExifInterface.TAG_FILE_SOURCE, + ExifInterface.TAG_FLASH, + ExifInterface.TAG_FLASHPIX_VERSION, + ExifInterface.TAG_FLASH_ENERGY, + ExifInterface.TAG_FOCAL_LENGTH, + ExifInterface.TAG_FOCAL_LENGTH_IN_35MM_FILM, + ExifInterface.TAG_FOCAL_PLANE_RESOLUTION_UNIT, + ExifInterface.TAG_FOCAL_PLANE_X_RESOLUTION, + ExifInterface.TAG_FOCAL_PLANE_Y_RESOLUTION, + ExifInterface.TAG_F_NUMBER, + ExifInterface.TAG_GAIN_CONTROL, + ExifInterface.TAG_GAMMA, + ExifInterface.TAG_GPS_ALTITUDE, + ExifInterface.TAG_GPS_ALTITUDE_REF, + ExifInterface.TAG_GPS_AREA_INFORMATION, + ExifInterface.TAG_GPS_DATESTAMP, + ExifInterface.TAG_GPS_DEST_BEARING, + ExifInterface.TAG_GPS_DEST_BEARING_REF, + ExifInterface.TAG_GPS_DEST_DISTANCE, + ExifInterface.TAG_GPS_DEST_DISTANCE_REF, + ExifInterface.TAG_GPS_DEST_LATITUDE, + ExifInterface.TAG_GPS_DEST_LATITUDE_REF, + ExifInterface.TAG_GPS_DEST_LONGITUDE, + ExifInterface.TAG_GPS_DEST_LONGITUDE_REF, + ExifInterface.TAG_GPS_DIFFERENTIAL, + ExifInterface.TAG_GPS_DOP, + ExifInterface.TAG_GPS_H_POSITIONING_ERROR, + ExifInterface.TAG_GPS_IMG_DIRECTION, + ExifInterface.TAG_GPS_IMG_DIRECTION_REF, + ExifInterface.TAG_GPS_LATITUDE, + ExifInterface.TAG_GPS_LATITUDE_REF, + ExifInterface.TAG_GPS_LONGITUDE, + ExifInterface.TAG_GPS_LONGITUDE_REF, + ExifInterface.TAG_GPS_MAP_DATUM, + ExifInterface.TAG_GPS_MEASURE_MODE, + ExifInterface.TAG_GPS_PROCESSING_METHOD, + ExifInterface.TAG_GPS_SATELLITES, + ExifInterface.TAG_GPS_SPEED, + ExifInterface.TAG_GPS_SPEED_REF, + ExifInterface.TAG_GPS_STATUS, + ExifInterface.TAG_GPS_TIMESTAMP, + ExifInterface.TAG_GPS_TRACK, + ExifInterface.TAG_GPS_TRACK_REF, + ExifInterface.TAG_GPS_VERSION_ID, + ExifInterface.TAG_IMAGE_DESCRIPTION, + ExifInterface.TAG_IMAGE_LENGTH, + ExifInterface.TAG_IMAGE_UNIQUE_ID, + ExifInterface.TAG_IMAGE_WIDTH, + ExifInterface.TAG_INTEROPERABILITY_INDEX, + ExifInterface.TAG_ISO_SPEED, + ExifInterface.TAG_ISO_SPEED_LATITUDE_YYY, + ExifInterface.TAG_ISO_SPEED_LATITUDE_ZZZ, + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT, + ExifInterface.TAG_JPEG_INTERCHANGE_FORMAT_LENGTH, + ExifInterface.TAG_LENS_MAKE, + ExifInterface.TAG_LENS_MODEL, + ExifInterface.TAG_LENS_SERIAL_NUMBER, + ExifInterface.TAG_LENS_SPECIFICATION, + ExifInterface.TAG_LIGHT_SOURCE, + ExifInterface.TAG_MAKE, + ExifInterface.TAG_MAKER_NOTE, + ExifInterface.TAG_MAX_APERTURE_VALUE, + ExifInterface.TAG_METERING_MODE, + ExifInterface.TAG_MODEL, + ExifInterface.TAG_NEW_SUBFILE_TYPE, + ExifInterface.TAG_OECF, + ExifInterface.TAG_OFFSET_TIME, + ExifInterface.TAG_OFFSET_TIME_DIGITIZED, + ExifInterface.TAG_OFFSET_TIME_ORIGINAL, + ExifInterface.TAG_ORF_ASPECT_FRAME, + ExifInterface.TAG_ORF_PREVIEW_IMAGE_LENGTH, + ExifInterface.TAG_ORF_PREVIEW_IMAGE_START, + ExifInterface.TAG_ORF_THUMBNAIL_IMAGE, + ExifInterface.TAG_ORIENTATION, + ExifInterface.TAG_PHOTOGRAPHIC_SENSITIVITY, + ExifInterface.TAG_PHOTOMETRIC_INTERPRETATION, + ExifInterface.TAG_PIXEL_X_DIMENSION, + ExifInterface.TAG_PIXEL_Y_DIMENSION, + ExifInterface.TAG_PLANAR_CONFIGURATION, + ExifInterface.TAG_PRIMARY_CHROMATICITIES, + ExifInterface.TAG_RECOMMENDED_EXPOSURE_INDEX, + ExifInterface.TAG_REFERENCE_BLACK_WHITE, + ExifInterface.TAG_RELATED_SOUND_FILE, + ExifInterface.TAG_RESOLUTION_UNIT, + ExifInterface.TAG_ROWS_PER_STRIP, + ExifInterface.TAG_RW2_ISO, + ExifInterface.TAG_RW2_JPG_FROM_RAW, + ExifInterface.TAG_RW2_SENSOR_BOTTOM_BORDER, + ExifInterface.TAG_RW2_SENSOR_LEFT_BORDER, + ExifInterface.TAG_RW2_SENSOR_RIGHT_BORDER, + ExifInterface.TAG_RW2_SENSOR_TOP_BORDER, + ExifInterface.TAG_SAMPLES_PER_PIXEL, + ExifInterface.TAG_SATURATION, + ExifInterface.TAG_SCENE_CAPTURE_TYPE, + ExifInterface.TAG_SCENE_TYPE, + ExifInterface.TAG_SENSING_METHOD, + ExifInterface.TAG_SENSITIVITY_TYPE, + ExifInterface.TAG_SHARPNESS, + ExifInterface.TAG_SHUTTER_SPEED_VALUE, + ExifInterface.TAG_SOFTWARE, + ExifInterface.TAG_SPATIAL_FREQUENCY_RESPONSE, + ExifInterface.TAG_SPECTRAL_SENSITIVITY, + ExifInterface.TAG_STANDARD_OUTPUT_SENSITIVITY, + ExifInterface.TAG_STRIP_BYTE_COUNTS, + ExifInterface.TAG_STRIP_OFFSETS, + ExifInterface.TAG_SUBFILE_TYPE, + ExifInterface.TAG_SUBJECT_AREA, + ExifInterface.TAG_SUBJECT_DISTANCE, + ExifInterface.TAG_SUBJECT_DISTANCE_RANGE, + ExifInterface.TAG_SUBJECT_LOCATION, + ExifInterface.TAG_SUBSEC_TIME, + ExifInterface.TAG_SUBSEC_TIME_DIGITIZED, + ExifInterface.TAG_SUBSEC_TIME_ORIGINAL, + ExifInterface.TAG_THUMBNAIL_IMAGE_LENGTH, + ExifInterface.TAG_THUMBNAIL_IMAGE_WIDTH, + ExifInterface.TAG_TRANSFER_FUNCTION, + ExifInterface.TAG_USER_COMMENT, + ExifInterface.TAG_WHITE_BALANCE, + ExifInterface.TAG_WHITE_POINT, + ExifInterface.TAG_XMP, + ExifInterface.TAG_X_RESOLUTION, + ExifInterface.TAG_Y_CB_CR_COEFFICIENTS, + ExifInterface.TAG_Y_CB_CR_POSITIONING, + ExifInterface.TAG_Y_CB_CR_SUB_SAMPLING, + ExifInterface.TAG_Y_RESOLUTION, + ) + // interpret EXIF code to angle (0, 90, 180 or 270 degrees) @JvmStatic - fun getOrientationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) { - ExifInterface.ORIENTATION_ROTATE_180 -> 180 - ExifInterface.ORIENTATION_ROTATE_90 -> 90 - ExifInterface.ORIENTATION_ROTATE_270 -> 270 - else -> 0 // all other orientations (regular, flipped...) default to an angle of 0 degree + fun getRotationDegreesForExifCode(exifOrientation: Int): Int = when (exifOrientation) { + ExifInterface.ORIENTATION_ROTATE_90, ExifInterface.ORIENTATION_TRANSVERSE -> 90 + ExifInterface.ORIENTATION_ROTATE_180, ExifInterface.ORIENTATION_FLIP_VERTICAL -> 180 + ExifInterface.ORIENTATION_ROTATE_270, ExifInterface.ORIENTATION_TRANSPOSE -> 270 + else -> 0 + } + + // interpret EXIF code to whether the image is flipped + @JvmStatic + fun isFlippedForExifCode(exifOrientation: Int): Boolean = when (exifOrientation) { + ExifInterface.ORIENTATION_FLIP_HORIZONTAL, ExifInterface.ORIENTATION_TRANSVERSE, ExifInterface.ORIENTATION_FLIP_VERTICAL, ExifInterface.ORIENTATION_TRANSPOSE -> true + else -> false } // yyyyMMddTHHmmss(.sss)?(Z|+/-hhmm)? diff --git a/lib/services/metadata_service.dart b/lib/services/metadata_service.dart index c728a0704..e3f8d05e3 100644 --- a/lib/services/metadata_service.dart +++ b/lib/services/metadata_service.dart @@ -89,6 +89,19 @@ class MetadataService { return {}; } + static Future getExifInterfaceMetadata(ImageEntry entry) async { + try { + // return map with all data available from the ExifInterface library + final result = await platform.invokeMethod('getExifInterfaceMetadata', { + 'uri': entry.uri, + }) as Map; + return result; + } on PlatformException catch (e) { + debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}'); + } + return {}; + } + static Future> getEmbeddedPictures(String uri) async { try { final result = await platform.invokeMethod('getEmbeddedPictures', { diff --git a/lib/widgets/fullscreen/debug.dart b/lib/widgets/fullscreen/debug.dart index 7dd763db8..6e7e4ddac 100644 --- a/lib/widgets/fullscreen/debug.dart +++ b/lib/widgets/fullscreen/debug.dart @@ -9,6 +9,7 @@ import 'package:aves/widgets/common/icons.dart'; import 'package:aves/widgets/common/image_providers/thumbnail_provider.dart'; import 'package:aves/widgets/common/image_providers/uri_picture_provider.dart'; import 'package:aves/widgets/fullscreen/info/info_page.dart'; +import 'package:aves/widgets/settings/settings_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:tuple/tuple.dart'; @@ -28,7 +29,7 @@ class _FullscreenDebugPageState extends State { Future _dbDateLoader; Future _dbMetadataLoader; Future _dbAddressLoader; - Future _contentResolverMetadataLoader; + Future _contentResolverMetadataLoader, _exifInterfaceMetadataLoader; ImageEntry get entry => widget.entry; @@ -259,31 +260,38 @@ class _FullscreenDebugPageState extends State { static const millisecondTimestampKeys = ['datetaken', 'datetime']; Widget _buildContentResolverTabView() { + Widget builder(BuildContext context, AsyncSnapshot snapshot) { + if (snapshot.hasError) return Text(snapshot.error.toString()); + if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); + final data = SplayTreeMap.of(snapshot.data.map((k, v) { + final key = k.toString(); + var value = v?.toString() ?? 'null'; + if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) { + if (secondTimestampKeys.contains(key)) { + v *= 1000; + } + value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})'; + } + if (key == 'xmp' && v != null && v is Uint8List) { + value = String.fromCharCodes(v); + } + return MapEntry(key, value); + })); + return InfoRowGroup(data); + } + return ListView( padding: EdgeInsets.all(16), children: [ - Text('Content Resolver (Media Store):'), + SectionTitle('Content Resolver (Media Store)'), FutureBuilder( future: _contentResolverMetadataLoader, - builder: (context, snapshot) { - if (snapshot.hasError) return Text(snapshot.error.toString()); - if (snapshot.connectionState != ConnectionState.done) return SizedBox.shrink(); - final data = SplayTreeMap.of(snapshot.data.map((k, v) { - final key = k.toString(); - var value = v?.toString() ?? 'null'; - if ([...secondTimestampKeys, ...millisecondTimestampKeys].contains(key) && v is num && v != 0) { - if (secondTimestampKeys.contains(key)) { - v *= 1000; - } - value += ' (${DateTime.fromMillisecondsSinceEpoch(v)})'; - } - if (key == 'xmp' && v != null && v is Uint8List) { - value = String.fromCharCodes(v); - } - return MapEntry(key, value); - })); - return InfoRowGroup(data); - }, + builder: builder, + ), + SectionTitle('Exif Interface'), + FutureBuilder( + future: _exifInterfaceMetadataLoader, + builder: builder, ), ], ); @@ -294,6 +302,7 @@ class _FullscreenDebugPageState extends State { _dbMetadataLoader = metadataDb.loadMetadataEntries().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhere((row) => row.contentId == contentId, orElse: () => null)); _contentResolverMetadataLoader = MetadataService.getContentResolverMetadata(entry); + _exifInterfaceMetadataLoader = MetadataService.getExifInterfaceMetadata(entry); setState(() {}); } }