diff --git a/.gitignore b/.gitignore index 7792c31..70497f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,75 @@ -.DS_Store .dart_tool/ .packages .pub/ build/ .idea/ *.iml \ No newline at end of file +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +pubspec.lock +build/ + +# Android related +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.java + +# iOS/XCode related +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a4b4f0..fd644bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,22 @@ +## 0.4.0 + +Add ```cleanCache``` api to clean the cache directory. + +Add ```mimeType``` attribute of ```Medium```. + +Add alternative media query syntax to support Android 11. + +Cache original image data to a cached file and keep original medium file extension in iOS. + +Fix a problem of collection possibly be nil. + +Update .gitignore file. + ## 0.3.0 -Force getFile to use high quality format for videos in iOS platform. +Force ```getFile``` to use high quality format for videos in iOS platform. -Add optional mediumType parameter of getAlbumThumbnail api method to display video thumbnail correctly. +Add optional ```mediumType``` parameter of ```getAlbumThumbnail``` api method to display video thumbnail correctly. ## 0.2.5 @@ -12,7 +26,7 @@ Remove ```MediumType``` parameter in ```_listMedia``` method. ## 0.2.4 -Add VideoProvider widget to play video in plugin example. +Add ```VideoProvider``` widget to play video in plugin example. ## 0.2.3 @@ -20,9 +34,9 @@ Add ```MediumType``` attribute in ```ThumbnailProvider```. Fix a bug that throw ```FileNotFountException``` when load image and video thumbnail doesn't exists on Android API 29+. -Change medium creationDate and modifiedDate precision from second to millisecond on iOS platform. +Change medium ```creationDate``` and ```modifiedDate``` precision from second to millisecond on iOS platform. -Add video duration attribute in Medium. +Add video duration attribute in ```Medium``. ## 0.2.0+1 diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index f6b961f..0000000 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/android/gradlew b/android/gradlew deleted file mode 100755 index 9d82f78..0000000 --- a/android/gradlew +++ /dev/null @@ -1,160 +0,0 @@ -#!/usr/bin/env bash - -############################################################################## -## -## Gradle start up script for UN*X -## -############################################################################## - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS="" - -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" - -warn ( ) { - echo "$*" -} - -die ( ) { - echo - echo "$*" - echo - exit 1 -} - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; -esac - -# Attempt to set APP_HOME -# Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi -done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin, switch paths to Windows format before running java -if $cygwin ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=$((i+1)) - done - case $i in - (0) set -- ;; - (1) set -- "$args0" ;; - (2) set -- "$args0" "$args1" ;; - (3) set -- "$args0" "$args1" "$args2" ;; - (4) set -- "$args0" "$args1" "$args2" "$args3" ;; - (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; - esac -fi - -# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules -function splitJvmOpts() { - JVM_OPTS=("$@") -} -eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS -JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" - -exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat deleted file mode 100644 index 8a0b282..0000000 --- a/android/gradlew.bat +++ /dev/null @@ -1,90 +0,0 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/android/src/main/kotlin/com/morbit/photogallery/PhotoGalleryPlugin.kt b/android/src/main/kotlin/com/morbit/photogallery/PhotoGalleryPlugin.kt index e5d3dc0..9fd6f02 100644 --- a/android/src/main/kotlin/com/morbit/photogallery/PhotoGalleryPlugin.kt +++ b/android/src/main/kotlin/com/morbit/photogallery/PhotoGalleryPlugin.kt @@ -10,6 +10,7 @@ import io.flutter.plugin.common.MethodChannel.Result import io.flutter.plugin.common.PluginRegistry.Registrar import android.graphics.Bitmap import java.io.ByteArrayOutputStream +import java.io.File import android.provider.MediaStore import android.content.Context import android.database.Cursor @@ -55,6 +56,7 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { MediaStore.Images.Media._ID, MediaStore.Images.Media.WIDTH, MediaStore.Images.Media.HEIGHT, + MediaStore.Images.Media.MIME_TYPE, MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.DATE_MODIFIED ) @@ -63,6 +65,7 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { MediaStore.Video.Media._ID, MediaStore.Video.Media.WIDTH, MediaStore.Video.Media.HEIGHT, + MediaStore.Video.Media.MIME_TYPE, MediaStore.Video.Media.DURATION, MediaStore.Video.Media.DATE_TAKEN, MediaStore.Video.Media.DATE_MODIFIED @@ -140,6 +143,10 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { result.success(v) }) } + "cleanCache" -> { + cleanCache() + result.success(null) + } else -> result.notImplemented() } } @@ -277,14 +284,9 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { val limit = take ?: (total - offset) this.context?.run { + val imageCursor: Cursor? - var imageCursor: Cursor? = null - - /** - * Change the way to fetch Media Store - */ - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - // Get All data in Cursor by sorting in DESC order + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { imageCursor = this.contentResolver.query( MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageMetadataProjection, @@ -292,41 +294,36 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { // Limit & Offset putInt(android.content.ContentResolver.QUERY_ARG_LIMIT, limit) putInt(android.content.ContentResolver.QUERY_ARG_OFFSET, offset) - // Sort function + // Sort putStringArray( - android.content.ContentResolver.QUERY_ARG_SORT_COLUMNS, - arrayOf( - MediaStore.Images.Media.DATE_TAKEN, - MediaStore.Images.Media.DATE_MODIFIED - ) + android.content.ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf( + MediaStore.Images.Media.DATE_TAKEN, + MediaStore.Images.Media.DATE_MODIFIED + ) ) putIntArray( - android.content.ContentResolver.QUERY_ARG_SORT_DIRECTION, - intArrayOf( - android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, - android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING - ) + android.content.ContentResolver.QUERY_ARG_SORT_DIRECTION, + intArrayOf( + android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING + ) ) // Selection if (albumId != allAlbumId) { putString(android.content.ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Images.Media.BUCKET_ID} = ?") - putStringArray( - android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, - arrayOf( - albumId.toString() - ) - ) + putStringArray(android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(albumId)) } }, - null + null ) } else { imageCursor = this.contentResolver.query( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - imageMetadataProjection, - if (albumId == allAlbumId) null else "${MediaStore.Images.Media.BUCKET_ID} = $albumId", - null, - "$imageOrderBy LIMIT $limit OFFSET $offset" + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + imageMetadataProjection, + if (albumId == allAlbumId) null else "${MediaStore.Images.Media.BUCKET_ID} = $albumId", + null, + "$imageOrderBy LIMIT $limit OFFSET $offset" ) } @@ -350,12 +347,48 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { val limit = take ?: (total - offset) this.context?.run { - val videoCursor = this.contentResolver.query( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, - videoMetadataProjection, - if (albumId == allAlbumId) null else "${MediaStore.Images.Media.BUCKET_ID} = $albumId", - null, - "$videoOrderBy LIMIT $limit OFFSET $offset") + val videoCursor: Cursor? + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + videoCursor = this.contentResolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + videoMetadataProjection, + android.os.Bundle().apply { + // Limit & Offset + putInt(android.content.ContentResolver.QUERY_ARG_LIMIT, limit) + putInt(android.content.ContentResolver.QUERY_ARG_OFFSET, offset) + // Sort + putStringArray( + android.content.ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf( + MediaStore.Video.Media.DATE_TAKEN, + MediaStore.Video.Media.DATE_MODIFIED + ) + ) + putIntArray( + android.content.ContentResolver.QUERY_ARG_SORT_DIRECTION, + intArrayOf( + android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING + ) + ) + // Selection + if (albumId != allAlbumId) { + putString(android.content.ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Video.Media.BUCKET_ID} = ?") + putStringArray(android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(albumId)) + } + }, + null + ) + } else { + videoCursor = this.contentResolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + videoMetadataProjection, + if (albumId == allAlbumId) null else "${MediaStore.Video.Media.BUCKET_ID} = $albumId", + null, + "$videoOrderBy LIMIT $limit OFFSET $offset" + ) + } videoCursor?.use { cursor -> while (cursor.moveToNext()) { @@ -510,27 +543,62 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { private fun getAlbumThumbnail(albumId: String, mediumType: String?, width: Int?, height: Int?): ByteArray? { return when (mediumType) { imageType -> { - getImageAlbumThubnail(albumId, width, height) + getImageAlbumThumbnail(albumId, width, height) } videoType -> { - getVideoAlbumThubnail(albumId, width, height) + getVideoAlbumThumbnail(albumId, width, height) } else -> { - getImageAlbumThubnail(albumId, width, height) - ?: getVideoAlbumThubnail(albumId, width, height) + getImageAlbumThumbnail(albumId, width, height) + ?: getVideoAlbumThumbnail(albumId, width, height) } } } - private fun getImageAlbumThubnail(albumId: String, width: Int?, height: Int?): ByteArray? { + private fun getImageAlbumThumbnail(albumId: String, width: Int?, height: Int?): ByteArray? { return this.context?.run { - val imageCursor = this.contentResolver.query( - MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - arrayOf(MediaStore.Images.Media._ID), - if (albumId == allAlbumId) null else "${MediaStore.Images.Media.BUCKET_ID} = $albumId", - null, - MediaStore.Images.Media.DATE_TAKEN + " DESC LIMIT 1" - ) + val imageCursor: Cursor? + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + imageCursor = this.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Images.Media._ID), + android.os.Bundle().apply { + // Limit + putInt(android.content.ContentResolver.QUERY_ARG_LIMIT, 1) + // Sort + putStringArray( + android.content.ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf( + MediaStore.Images.Media.DATE_TAKEN, + MediaStore.Images.Media.DATE_MODIFIED + ) + ) + putIntArray( + android.content.ContentResolver.QUERY_ARG_SORT_DIRECTION, + intArrayOf( + android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING + ) + ) + // Selection + if (albumId != allAlbumId) { + putString(android.content.ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Images.Media.BUCKET_ID} = ?") + putStringArray(android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(albumId)) + } + }, + null + ) + } else { + imageCursor = this.contentResolver.query( + MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Images.Media._ID), + if (albumId == allAlbumId) null else "${MediaStore.Images.Media.BUCKET_ID} = $albumId", + null, + MediaStore.Images.Media.DATE_TAKEN + " DESC LIMIT 1" + ) + } + imageCursor?.use { cursor -> if (cursor.moveToFirst()) { val idColumn = cursor.getColumnIndex(MediaStore.Images.Media._ID) @@ -543,15 +611,50 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { } } - private fun getVideoAlbumThubnail(albumId: String, width: Int?, height: Int?): ByteArray? { + private fun getVideoAlbumThumbnail(albumId: String, width: Int?, height: Int?): ByteArray? { return this.context?.run { - val videoCursor = this.contentResolver.query( - MediaStore.Video.Media.EXTERNAL_CONTENT_URI, - arrayOf(MediaStore.Video.Media._ID), - if (albumId == allAlbumId) null else "${MediaStore.Video.Media.BUCKET_ID} = $albumId", - null, - MediaStore.Video.Media.DATE_TAKEN + " DESC LIMIT 1" - ) + val videoCursor: Cursor? + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + videoCursor = this.contentResolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Video.Media._ID), + android.os.Bundle().apply { + // Limit + putInt(android.content.ContentResolver.QUERY_ARG_LIMIT, 1) + // Sort + putStringArray( + android.content.ContentResolver.QUERY_ARG_SORT_COLUMNS, + arrayOf( + MediaStore.Video.Media.DATE_TAKEN, + MediaStore.Video.Media.DATE_MODIFIED + ) + ) + putIntArray( + android.content.ContentResolver.QUERY_ARG_SORT_DIRECTION, + intArrayOf( + android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING, + android.content.ContentResolver.QUERY_SORT_DIRECTION_DESCENDING + ) + ) + // Selection + if (albumId != allAlbumId) { + putString(android.content.ContentResolver.QUERY_ARG_SQL_SELECTION, "${MediaStore.Video.Media.BUCKET_ID} = ?") + putStringArray(android.content.ContentResolver.QUERY_ARG_SQL_SELECTION_ARGS, arrayOf(albumId)) + } + }, + null + ) + } else { + videoCursor = this.contentResolver.query( + MediaStore.Video.Media.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.Video.Media._ID), + if (albumId == allAlbumId) null else "${MediaStore.Video.Media.BUCKET_ID} = $albumId", + null, + MediaStore.Video.Media.DATE_TAKEN + " DESC LIMIT 1" + ) + } + videoCursor?.use { cursor -> if (cursor.moveToNext()) { val idColumn = cursor.getColumnIndex(MediaStore.Video.Media._ID) @@ -627,12 +730,14 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { val idColumn = cursor.getColumnIndex(MediaStore.Images.Media._ID) val widthColumn = cursor.getColumnIndex(MediaStore.Images.Media.WIDTH) val heightColumn = cursor.getColumnIndex(MediaStore.Images.Media.HEIGHT) + val mimeColumn = cursor.getColumnIndex(MediaStore.Images.Media.MIME_TYPE) val dateTakenColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATE_TAKEN) val dateModifiedColumn = cursor.getColumnIndex(MediaStore.Images.Media.DATE_MODIFIED) val id = cursor.getLong(idColumn) val width = cursor.getLong(widthColumn) val height = cursor.getLong(heightColumn) + val mimeType = cursor.getString(mimeColumn) var dateTaken: Long? = null if (cursor.getType(dateTakenColumn) == FIELD_TYPE_INTEGER) { dateTaken = cursor.getLong(dateTakenColumn) @@ -647,6 +752,7 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { "mediumType" to imageType, "width" to width, "height" to height, + "mimeType" to mimeType, "creationDate" to dateTaken, "modifiedDate" to dateModified ) @@ -656,6 +762,7 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { val idColumn = cursor.getColumnIndex(MediaStore.Video.Media._ID) val widthColumn = cursor.getColumnIndex(MediaStore.Video.Media.WIDTH) val heightColumn = cursor.getColumnIndex(MediaStore.Video.Media.HEIGHT) + val mimeColumn = cursor.getColumnIndex(MediaStore.Video.Media.MIME_TYPE) val durationColumn = cursor.getColumnIndex(MediaStore.Video.Media.DURATION) val dateTakenColumn = cursor.getColumnIndex(MediaStore.Video.Media.DATE_TAKEN) val dateModifiedColumn = cursor.getColumnIndex(MediaStore.Video.Media.DATE_MODIFIED) @@ -663,6 +770,7 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { val id = cursor.getLong(idColumn) val width = cursor.getLong(widthColumn) val height = cursor.getLong(heightColumn) + val mimeType = cursor.getString(mimeColumn) val duration = cursor.getLong(durationColumn) var dateTaken: Long? = null if (cursor.getType(dateTakenColumn) == FIELD_TYPE_INTEGER) { @@ -678,11 +786,27 @@ class PhotoGalleryPlugin : FlutterPlugin, MethodCallHandler { "mediumType" to videoType, "width" to width, "height" to height, + "mimeType" to mimeType, "duration" to duration, "creationDate" to dateTaken, "modifiedDate" to dateModified ) } + + private fun getCachePath(): File? { + return this.context?.run { + val cachePath = File(this.cacheDir, "photo_gallery") + if (!cachePath.exists()) { + cachePath.mkdirs() + } + return@run cachePath + } + } + + private fun cleanCache() { + val cachePath = getCachePath() + cachePath?.deleteRecursively() + } } class BackgroundAsyncTask(val handler: () -> T, val post: (result: T) -> Unit) : AsyncTask() { diff --git a/example/pubspec.lock b/example/pubspec.lock deleted file mode 100644 index e84427a..0000000 --- a/example/pubspec.lock +++ /dev/null @@ -1,250 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.13" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.0" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.3" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.14.12" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.4" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.3" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - image: - dependency: transitive - description: - name: image - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.12" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.6" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.8" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.4" - permission_handler: - dependency: "direct main" - description: - name: permission_handler - url: "https://pub.dartlang.org" - source: hosted - version: "5.0.1+1" - permission_handler_platform_interface: - dependency: transitive - description: - name: permission_handler_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.0" - photo_gallery: - dependency: "direct main" - description: - path: ".." - relative: true - source: path - version: "0.3.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.2" - quiver: - dependency: transitive - description: - name: quiver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.3" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.3" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.15" - transparent_image: - dependency: "direct main" - description: - name: transparent_image - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.6" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" - video_player: - dependency: "direct main" - description: - name: video_player - url: "https://pub.dartlang.org" - source: hosted - version: "0.10.12" - video_player_platform_interface: - dependency: transitive - description: - name: video_player_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - video_player_web: - dependency: transitive - description: - name: video_player_web - url: "https://pub.dartlang.org" - source: hosted - version: "0.1.3+2" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "3.6.1" -sdks: - dart: ">=2.7.0 <3.0.0" - flutter: ">=1.12.13+hotfix.5 <2.0.0" diff --git a/ios/Classes/SwiftPhotoGalleryPlugin.swift b/ios/Classes/SwiftPhotoGalleryPlugin.swift index 5557628..97e3504 100644 --- a/ios/Classes/SwiftPhotoGalleryPlugin.swift +++ b/ios/Classes/SwiftPhotoGalleryPlugin.swift @@ -75,6 +75,10 @@ public class SwiftPhotoGalleryPlugin: NSObject, FlutterPlugin { result(filepath?.replacingOccurrences(of: "file://", with: "")) }) } + else if(call.method == "cleanCache") { + cleanCache() + result(nil) + } else { result(FlutterMethodNotImplemented) } @@ -161,7 +165,7 @@ public class SwiftPhotoGalleryPlugin: NSObject, FlutterPlugin { return PHAsset.fetchAssets(with: options).count } - return PHAsset.fetchAssets(in: collection!, options: options).count + return PHAsset.fetchAssets(in: collection ?? PHAssetCollection.init(), options: options).count } private func listMedia(albumId: String, skip: NSNumber?, take: NSNumber?, mediumType: String) -> NSDictionary { @@ -175,7 +179,7 @@ public class SwiftPhotoGalleryPlugin: NSObject, FlutterPlugin { let fetchResult = albumId == "__ALL__" ? PHAsset.fetchAssets(with: fetchOptions) - : PHAsset.fetchAssets(in: collection!, options: fetchOptions) + : PHAsset.fetchAssets(in: collection ?? PHAssetCollection.init(), options: fetchOptions) let start = skip?.intValue ?? 0 let total = fetchResult.count let end = take == nil ? total : min(start + take!.intValue, total) @@ -330,20 +334,21 @@ public class SwiftPhotoGalleryPlugin: NSObject, FlutterPlugin { options: options, resultHandler: { (data: Data?, uti: String?, orientation, info) in DispatchQueue.main.async(execute: { - guard let originalData = data else { + guard let imageData = data else { completion(nil, NSError(domain: "photo_gallery", code: 404, userInfo: nil)) return } - guard let jpgData = self.convertToJpeg(originalData: originalData) else { - completion(nil, NSError(domain: "photo_gallery", code: 500, userInfo: nil)) + guard let assetUTI = uti else { + completion(nil, NSError(domain: "photo_gallery", code: 404, userInfo: nil)) return } - // Writing to file - let filepath = self.exportPathForAsset(asset: asset, ext: ".jpg") - try! jpgData.write(to: filepath, options: .atomic) + let fileExt = self.extractFileExtensionFromUTI(uti: assetUTI) + let filepath = self.exportPathForAsset(asset: asset, ext: fileExt) + try! imageData.write(to: filepath, options: .atomic) completion(filepath.absoluteString, nil) }) - }) + } + ) } else if(asset.mediaType == PHAssetMediaType.video || asset.mediaType == PHAssetMediaType.audio) { let options = PHVideoRequestOptions() @@ -356,22 +361,26 @@ public class SwiftPhotoGalleryPlugin: NSObject, FlutterPlugin { do { let avAsset = avAsset as? AVURLAsset let data = try Data(contentsOf: avAsset!.url) - let filepath = self.exportPathForAsset(asset: asset, ext: ".mov") + let fileExt = self.extractFileExtensionFromAsset(asset: asset) + let filepath = self.exportPathForAsset(asset: asset, ext: fileExt) try! data.write(to: filepath, options: .atomic) completion(filepath.absoluteString, nil) } catch { completion(nil, NSError(domain: "photo_gallery", code: 500, userInfo: nil)) } }) - }) + } + ) } } } private func getMediumFromAsset(asset: PHAsset) -> [String: Any?] { + let mimeType = self.extractMimeTypeFromAsset(asset: asset) return [ "id": asset.localIdentifier, "mediumType": toDartMediumType(value: asset.mediaType), + "mimeType": mimeType, "height": asset.pixelHeight, "width": asset.pixelWidth, "duration": NSInteger(asset.duration * 1000), @@ -379,37 +388,13 @@ public class SwiftPhotoGalleryPlugin: NSObject, FlutterPlugin { "modifiedDate": (asset.modificationDate != nil) ? NSInteger(asset.modificationDate!.timeIntervalSince1970 * 1000) : nil ] } - - /// Converts to JPEG, and keep EXIF data. - private func convertToJpeg(originalData: Data) -> Data? { - guard let image: UIImage = UIImage(data: originalData) else { return nil } - - let originalSrc = CGImageSourceCreateWithData(originalData as CFData, nil)! - let options = [kCGImageSourceShouldCache as String: kCFBooleanFalse] - let originalMetadata = CGImageSourceCopyPropertiesAtIndex(originalSrc, 0, options as CFDictionary) - - guard let jpeg = image.jpegData(compressionQuality: 1.0) else { return nil } - - let src = CGImageSourceCreateWithData(jpeg as CFData, nil)! - let data = NSMutableData() - let uti = CGImageSourceGetType(src)! - let dest = CGImageDestinationCreateWithData(data as CFMutableData, uti, 1, nil)! - CGImageDestinationAddImageFromSource(dest, src, 0, originalMetadata) - if !CGImageDestinationFinalize(dest) { return nil } - - return data as Data - } - - + private func exportPathForAsset(asset: PHAsset, ext: String) -> URL { let mediumId = asset.localIdentifier .replacingOccurrences(of: "/", with: "__") .replacingOccurrences(of: "\\", with: "__") - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - let tempFolder = paths[0].appendingPathComponent("photo_gallery") - try! FileManager.default.createDirectory(at: tempFolder, withIntermediateDirectories: true, attributes: nil) - - return paths[0].appendingPathComponent(mediumId+ext) + let cachePath = self.cachePath() + return cachePath.appendingPathComponent(mediumId + ext) } private func toSwiftMediumType(value: String) -> PHAssetMediaType? { @@ -441,4 +426,55 @@ public class SwiftPhotoGalleryPlugin: NSObject, FlutterPlugin { let swiftType = toSwiftMediumType(value: mediumType) return NSPredicate(format: "mediaType = %d", swiftType!.rawValue) } + + private func extractFileExtensionFromUTI(uti: String?) -> String { + guard let assetUTI = uti else { + return "" + } + guard let ext = UTTypeCopyPreferredTagWithClass(assetUTI as CFString, kUTTagClassFilenameExtension as CFString)?.takeRetainedValue() as String? else { + return "" + } + return "." + ext + } + + private func extractMimeTypeFromUTI(uti: String?) -> String? { + guard let assetUTI = uti else { + return nil + } + guard let mimeType = UTTypeCopyPreferredTagWithClass(assetUTI as CFString, kUTTagClassMIMEType as CFString)?.takeRetainedValue() as String? else { + return nil + } + return mimeType + } + + private func extractUTIFromAsset(asset: PHAsset) -> String? { + if #available(iOS 9, *) { + let resourceList = PHAssetResource.assetResources(for: asset) + if let resource = resourceList.first { + return resource.uniformTypeIdentifier + } + } + return asset.value(forKey: "uniformTypeIdentifier") as? String + } + + private func extractFileExtensionFromAsset(asset: PHAsset) -> String { + let uti = self.extractUTIFromAsset(asset: asset) + return self.extractFileExtensionFromUTI(uti: uti) + } + + private func extractMimeTypeFromAsset(asset: PHAsset) -> String? { + let uti = self.extractUTIFromAsset(asset: asset) + return self.extractMimeTypeFromUTI(uti: uti) + } + + private func cachePath() -> URL { + let paths = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask) + let cacheFolder = paths[0].appendingPathComponent("photo_gallery") + try! FileManager.default.createDirectory(at: cacheFolder, withIntermediateDirectories: true, attributes: nil) + return cacheFolder + } + + private func cleanCache() { + try? FileManager.default.removeItem(at: self.cachePath()) + } } diff --git a/lib/photo_gallery.dart b/lib/photo_gallery.dart index abcc609..a7548cc 100644 --- a/lib/photo_gallery.dart +++ b/lib/photo_gallery.dart @@ -113,4 +113,8 @@ class PhotoGallery { }) as String; return File(path); } + + static Future cleanCache() async { + _channel.invokeMethod('cleanCache', {}); + } } diff --git a/lib/src/models/medium.dart b/lib/src/models/medium.dart index f5a08c6..7c629d3 100644 --- a/lib/src/models/medium.dart +++ b/lib/src/models/medium.dart @@ -17,6 +17,9 @@ class Medium { /// The medium height. final int height; + /// The medium mimeType. + final String mimeType; + /// The duration of video final int duration; @@ -31,6 +34,7 @@ class Medium { this.mediumType, this.width, this.height, + this.mimeType, this.duration, this.creationDate, this.modifiedDate, @@ -42,6 +46,7 @@ class Medium { mediumType = jsonToMediumType(json["mediumType"]), width = json["width"], height = json["height"], + mimeType = json["mimeType"], duration = json['duration'] ?? 0, creationDate = json['creationDate'] != null ? DateTime.fromMillisecondsSinceEpoch(json['creationDate']) @@ -56,6 +61,7 @@ class Medium { mediumType: jsonToMediumType(map['mediumType']), width: map['width'], height: map['height'], + mimeType: map["mimeType"], creationDate: map['creationDate'], modifiedDate: map['modifiedDate'], ); @@ -66,6 +72,7 @@ class Medium { "id": this.id, "mediumType": mediumTypeToJson(this.mediumType), "height": this.height, + "mimeType": this.mimeType, "width": this.width, "creationDate": this.creationDate, "modifiedDate": this.modifiedDate, @@ -104,6 +111,7 @@ class Medium { mediumType == other.mediumType && width == other.width && height == other.height && + mimeType == other.mimeType && creationDate == other.creationDate && modifiedDate == other.modifiedDate; @@ -113,6 +121,7 @@ class Medium { mediumType.hashCode ^ width.hashCode ^ height.hashCode ^ + mimeType.hashCode ^ creationDate.hashCode ^ modifiedDate.hashCode; @@ -122,6 +131,7 @@ class Medium { 'mediumType: $mediumType, ' 'width: $width, ' 'height: $height, ' + 'mimeType: $mimeType, ' 'creationDate: $creationDate, ' 'modifiedDate: $modifiedDate}'; } diff --git a/pubspec.lock b/pubspec.lock deleted file mode 100644 index 4270840..0000000 --- a/pubspec.lock +++ /dev/null @@ -1,182 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - archive: - dependency: transitive - description: - name: archive - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.13" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.0" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.3" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.14.12" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.4" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - image: - dependency: transitive - description: - name: image - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.12" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.6" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.8" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.6.4" - petitparser: - dependency: transitive - description: - name: petitparser - url: "https://pub.dartlang.org" - source: hosted - version: "2.4.0" - quiver: - dependency: transitive - description: - name: quiver - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.3" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.9.3" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.5" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.15" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.6" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.8" - xml: - dependency: transitive - description: - name: xml - url: "https://pub.dartlang.org" - source: hosted - version: "3.6.1" -sdks: - dart: ">=2.7.0 <3.0.0" - flutter: ">=1.10.0" diff --git a/pubspec.yaml b/pubspec.yaml index 481e865..923359e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: photo_gallery description: A Flutter plugin that retrieves images and videos from mobile native gallery. -version: 0.3.0 +version: 0.4.0 repository: https://github.com/Firelands128/photo_gallery environment: diff --git a/test/utils/generator.dart b/test/utils/generator.dart index cfe1024..54c3ddc 100644 --- a/test/utils/generator.dart +++ b/test/utils/generator.dart @@ -61,7 +61,10 @@ class Generator { "mediumType": mediumTypeToJson(mediumType), "width": 512, "height": 512, + "mimeType": "image/jpeg", + "duration": 3600, "creationDate": DateTime(2020, 8, 1).millisecondsSinceEpoch, + "modifiedDate": DateTime(2020, 9, 1).millisecondsSinceEpoch, }; }