From 72d3e111354a4dcb6524c63b6e6dbe7e20f73622 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 18 Jul 2020 11:06:58 +0900 Subject: [PATCH 1/4] CI: release as beta to Play Store --- .github/workflows/main.yml | 27 +++++++++++++++++++++++++++ whatsnew/whatsnew-en-US | 1 + 2 files changed, 28 insertions(+) create mode 100644 whatsnew/whatsnew-en-US diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3b0746918..5711c7d39 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -60,3 +60,30 @@ jobs: with: artifacts: "build/app/outputs/apk/release/*.apk,build/app/outputs/bundle/release/*.aab" token: ${{ secrets.RELEASE_WORKFLOW_TOKEN }} + + - name: Upload app bundle + uses: actions/upload-artifact@v2 + with: + name: appbundle + path: build/app/outputs/bundle/release/app-release.aab + + release: + name: Create beta release on Play Store. + needs: [ build ] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + - name: Get appbundle from artifacts. + uses: actions/download-artifact@v2 + with: + name: appbundle + + - name: Release app to beta channel. + uses: r0adkll/upload-google-play@v1 + with: + serviceAccountJsonPlainText: ${{ secrets.PLAYSTORE_ACCOUNT_KEY }} + packageName: deckers.thibault.aves + releaseFile: app-release.aab + track: beta + whatsNewDirectory: whatsnew \ No newline at end of file diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US new file mode 100644 index 000000000..5c9bc4116 --- /dev/null +++ b/whatsnew/whatsnew-en-US @@ -0,0 +1 @@ +Thanks for using Aves! From 80a591ca8203635785bc28f9d4caf2f1f364b482 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 18 Jul 2020 16:20:38 +0900 Subject: [PATCH 2/4] improved file move with scoped storage --- android/app/src/main/AndroidManifest.xml | 2 +- .../channel/streams/ImageOpStreamHandler.java | 5 + .../aves/model/provider/ImageProvider.java | 88 +++++++----- .../provider/MediaStoreImageProvider.java | 129 +++++++++--------- .../aves/utils/PermissionManager.java | 9 -- 5 files changed, 125 insertions(+), 108 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 09202053a..3f54529e3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -30,7 +30,7 @@ - diff --git a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java index 7d13671b5..82f3d3825 100644 --- a/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java +++ b/android/app/src/main/java/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.java @@ -6,6 +6,7 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; +import java.io.File; import java.util.ArrayList; import java.util.HashMap; import java.util.List; @@ -94,6 +95,10 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler { String destinationDir = (String) argMap.get("destinationPath"); if (copy == null || destinationDir == null) return; + if (!destinationDir.endsWith(File.separator)) { + destinationDir += File.separator; + } + List entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList()); provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() { @Override diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java index d05ad556a..391d1ca3c 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/ImageProvider.java @@ -56,7 +56,7 @@ public abstract class ImageProvider { return Futures.immediateFailedFuture(new UnsupportedOperationException()); } - public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List entries, @NonNull ImageOpCallback callback) { + public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { callback.onFailure(new UnsupportedOperationException()); } @@ -66,12 +66,11 @@ public abstract class ImageProvider { return; } - Map newFields = new HashMap<>(); File oldFile = new File(oldPath); File newFile = new File(oldFile.getParent(), newFilename); if (oldFile.equals(newFile)) { Log.w(LOG_TAG, "new name and old name are the same, path=" + oldPath); - callback.onSuccess(newFields); + callback.onSuccess(new HashMap<>()); return; } @@ -94,40 +93,7 @@ public abstract class ImageProvider { } MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null); - MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, newUri) -> { - Log.d(LOG_TAG, "onScanCompleted with newPath=" + newPath + ", newUri=" + newUri); - if (newUri != null) { - // newURI is a file media URI (e.g. "content://media/12a9-8b42/file/62872") - // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") - long contentId = ContentUris.parseId(newUri); - Uri contentUri = null; - if (mimeType.startsWith(MimeTypes.IMAGE)) { - contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId); - } else if (mimeType.startsWith(MimeTypes.VIDEO)) { - contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId); - } - if (contentUri != null) { - // we retrieve updated fields as the renamed file became a new entry in the Media Store - String[] projection = {MediaStore.MediaColumns.DATA, MediaStore.MediaColumns.TITLE}; - try { - Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, null); - if (cursor != null) { - if (cursor.moveToNext()) { - newFields.put("uri", contentUri.toString()); - newFields.put("contentId", contentId); - newFields.put("path", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA))); - newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE))); - } - cursor.close(); - } - } catch (Exception e) { - callback.onFailure(e); - return; - } - } - } - callback.onSuccess(newFields); - }); + scanNewPath(activity, newFile.getPath(), mimeType, callback); } public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback callback) { @@ -291,6 +257,54 @@ public abstract class ImageProvider { // } } + protected void scanNewPath(final Activity activity, final String path, final String mimeType, final ImageOpCallback callback) { + MediaScannerConnection.scanFile(activity, new String[]{path}, new String[]{mimeType}, (newPath, newUri) -> { + Log.d(LOG_TAG, "scanNewPath onScanCompleted with newPath=" + newPath + ", newUri=" + newUri); + + long contentId = 0; + Uri contentUri = null; + if (newUri != null) { + // newURI is possibly a file media URI (e.g. "content://media/12a9-8b42/file/62872") + // but we need an image/video media URI (e.g. "content://media/external/images/media/62872") + contentId = ContentUris.parseId(newUri); + if (mimeType.startsWith(MimeTypes.IMAGE)) { + contentUri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, contentId); + } else if (mimeType.startsWith(MimeTypes.VIDEO)) { + contentUri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentId); + } + } + if (contentUri == null) { + callback.onFailure(new Exception("failed to get content URI of item at path=" + path)); + return; + } + + Map newFields = new HashMap<>(); + // we retrieve updated fields as the renamed file became a new entry in the Media Store + String[] projection = {MediaStore.MediaColumns.TITLE}; + try { + Cursor cursor = activity.getContentResolver().query(contentUri, projection, null, null, null); + if (cursor != null) { + if (cursor.moveToNext()) { + newFields.put("uri", contentUri.toString()); + newFields.put("contentId", contentId); + newFields.put("path", path); + newFields.put("title", cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.TITLE))); + } + cursor.close(); + } + } catch (Exception e) { + callback.onFailure(e); + return; + } + + if (newFields.isEmpty()) { + callback.onFailure(new Exception("failed to get item details from provider at contentUri=" + contentUri)); + } else { + callback.onSuccess(newFields); + } + }); + } + public interface ImageOpCallback { void onSuccess(Map fields); diff --git a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java index 90abe66dc..5a5f0fae8 100644 --- a/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java +++ b/android/app/src/main/java/deckers/thibault/aves/model/provider/MediaStoreImageProvider.java @@ -6,7 +6,6 @@ import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; -import android.media.MediaScannerConnection; import android.net.Uri; import android.os.Build; import android.os.Handler; @@ -17,6 +16,7 @@ import android.provider.MediaStore; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; import com.commonsware.cwac.document.DocumentFileCompat; import com.google.common.util.concurrent.ListenableFuture; @@ -275,7 +275,7 @@ public class MediaStoreImageProvider extends ImageProvider { } @Override - public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List entries, @NonNull ImageOpCallback callback) { + public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List entries, @NonNull final ImageOpCallback callback) { if (PermissionManager.requireVolumeAccessDialog(activity, destinationDir)) { Runnable runnable = () -> moveMultiple(activity, copy, destinationDir, entries, callback); new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, destinationDir, runnable)); @@ -288,7 +288,11 @@ public class MediaStoreImageProvider extends ImageProvider { return; } - String volumeName = null; + MediaStoreMoveDestination destination = new MediaStoreMoveDestination(activity, destinationDir); + if (destination.volumePath == null) { + callback.onFailure(new Exception("failed to set up destination volume path for path=" + destinationDir)); + return; + } for (AvesImageEntry entry : entries) { Uri sourceUri = entry.uri; @@ -298,16 +302,16 @@ public class MediaStoreImageProvider extends ImageProvider { Map result = new HashMap() {{ put("uri", sourceUri.toString()); }}; + + // TODO TLAD check if there is any downside to use tree document files with scoped storage on API 30+ + // when testing scoped storage on API 29, it seems less constraining to use tree document files than to rely on the Media Store try { ListenableFuture> newFieldsFuture; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - if (volumeName == null) { - volumeName = getVolumeName(activity, destinationDir); - } - newFieldsFuture = moveSingleByMediaStoreInsert(activity, sourcePath, sourceUri, destinationDir, volumeName, mimeType, copy); - } else { - newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy); - } +// if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) { +// newFieldsFuture = moveSingleByMediaStoreInsert(activity, sourcePath, sourceUri, destination, mimeType, copy); +// } else { + newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy); +// } Map newFields = newFieldsFuture.get(); result.put("success", true); result.put("newFields", newFields); @@ -324,29 +328,32 @@ public class MediaStoreImageProvider extends ImageProvider { // - the volume name should be lower case, not exactly as the `StorageVolume` UUID // - inserting on a removable volume works on API 29, but not on API 25 nor 26 (on which API/devices does it work?) // - there is no documentation regarding support for usage with removable storage - private ListenableFuture> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final String volumeName, final String mimeType, final boolean copy) { + // - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage + @RequiresApi(api = Build.VERSION_CODES.Q) + private ListenableFuture> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri, + final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) { SettableFuture> future = SettableFuture.create(); try { - String destinationPath = destinationDir + File.separator + new File(sourcePath).getName(); - - // from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device) - // from API 26, retrieve document URI from mediastore URI with `MediaStore.getDocumentUri(...)` + String displayName = new File(sourcePath).getName(); + String destinationFilePath = destination.fullPath + displayName; ContentValues contentValues = new ContentValues(); - contentValues.put(MediaStore.MediaColumns.DATA, destinationPath); + contentValues.put(MediaStore.MediaColumns.DATA, destinationFilePath); contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); - // TODO TLAD when not using legacy storage (~Q, R+), provide relative path (assess first whether the root is the "Pictures" folder or the root) -// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, ""); -// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, ""); - Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName); + // from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device) + contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath); + contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName); + Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? + MediaStore.Video.Media.getContentUri(destination.volumeName) : + MediaStore.Images.Media.getContentUri(destination.volumeName); Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues); if (destinationUri == null) { future.setException(new Exception("failed to insert row to content resolver")); } else { - DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri); - DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri); - source.copyTo(destination); + DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(activity, sourceUri); + DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(activity, destinationUri); + sourceFile.copyTo(destinationFile); boolean deletedSource = false; if (!copy) { @@ -362,7 +369,7 @@ public class MediaStoreImageProvider extends ImageProvider { Map newFields = new HashMap<>(); newFields.put("uri", destinationUri.toString()); newFields.put("contentId", ContentUris.parseId(destinationUri)); - newFields.put("path", destinationPath); + newFields.put("path", destinationFilePath); newFields.put("deletedSource", deletedSource); future.set(newFields); } @@ -376,30 +383,33 @@ public class MediaStoreImageProvider extends ImageProvider { // We can create an item via `DocumentFile.createFile()`, but: // - we need to scan the file to get the Media Store content URI - // - there is no control on the filename (derived from the display name, MIME type) + // - the underlying document provider controls the new file name private ListenableFuture> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) { SettableFuture> future = SettableFuture.create(); try { - // TODO TLAD more robust `destinationPath`, as it could be broken: - // - if a file with the same name already exists, and the name gets appended ` (1)` - // - if the original extension does not match the appended extension from the provided MIME type - final String fileName = new File(sourcePath).getName(); - final String displayName = fileName.replaceFirst("[.][^.]+$", ""); - String destinationPath = destinationDir + File.separator + fileName; + String sourceFileName = new File(sourcePath).getName(); + String desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$", ""); - DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri); // the file created from a `TreeDocumentFile` is also a `TreeDocumentFile` // but in order to open an output stream to it, we need to use a `SingleDocumentFile` // through a document URI, not a tree URI - DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, displayName); + // note that `DocumentFile.getParentFile()` returns null if we did not pick a tree first + DocumentFileCompat destinationTreeFile = destinationDirDocFile.createFile(mimeType, desiredNameWithoutExtension); DocumentFileCompat destinationDocFile = DocumentFileCompat.fromSingleUri(activity, destinationTreeFile.getUri()); - // `DocumentFile.getParentFile()` is null without picking a tree first + // `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry // `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument" // when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri` + DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri); source.copyTo(destinationDocFile); + // the source file name and the created document file name can be different when: + // - a file with the same name already exists, so the name gets a suffix like ` (1)` + // - the original extension does not match the extension appended used by the underlying provider + String fileName = destinationDocFile.getName(); + String destinationFullPath = destinationDir + fileName; + boolean deletedSource = false; if (!copy) { // delete original entry @@ -412,34 +422,17 @@ public class MediaStoreImageProvider extends ImageProvider { } boolean finalDeletedSource = deletedSource; - MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> { - Map newFields = new HashMap<>(); - if (newUri != null) { - // TODO TLAD check whether newURI is a file media URI (cf case in `rename`) - // we retrieve updated fields as the moved file became a new entry in the Media Store - String[] projection = {MediaStore.MediaColumns._ID}; - try { - Cursor cursor = activity.getContentResolver().query(newUri, projection, null, null, null); - if (cursor != null) { - if (cursor.moveToNext()) { - long contentId = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)); - newFields.put("uri", newUri.toString()); - newFields.put("contentId", contentId); - newFields.put("path", destinationPath); - newFields.put("deletedSource", finalDeletedSource); - } - cursor.close(); - } - } catch (Exception e) { - future.setException(e); - return; - } - } - if (newFields.isEmpty()) { - future.setException(new Exception("failed to scan moved item at path=" + destinationPath)); - } else { + scanNewPath(activity, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() { + @Override + public void onSuccess(Map newFields) { + newFields.put("deletedSource", finalDeletedSource); future.set(newFields); } + + @Override + public void onFailure(Throwable throwable) { + future.setException(throwable); + } }); } catch (Exception e) { Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e); @@ -456,4 +449,18 @@ public class MediaStoreImageProvider extends ImageProvider { public interface NewEntryChecker { boolean where(int contentId, int dateModifiedSecs); } + + class MediaStoreMoveDestination { + final String volumeName; + final String volumePath; + final String relativePath; + final String fullPath; + + MediaStoreMoveDestination(Activity activity, String destinationDir) { + fullPath = destinationDir; + volumeName = getVolumeName(activity, destinationDir); + volumePath = StorageUtils.getVolumePath(activity, destinationDir).orElse(null); + relativePath = volumePath != null ? destinationDir.replaceFirst(volumePath, "") : null; + } + } } \ No newline at end of file diff --git a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java index 5c29c5a9f..4ab6c92d1 100644 --- a/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java +++ b/android/app/src/main/java/deckers/thibault/aves/utils/PermissionManager.java @@ -7,7 +7,6 @@ import android.net.Uri; import android.os.Build; import android.os.storage.StorageManager; import android.os.storage.StorageVolume; -import android.provider.DocumentsContract; import android.util.Log; import androidx.annotation.NonNull; @@ -28,7 +27,6 @@ public class PermissionManager { // permission request code to pending runnable private static ConcurrentHashMap pendingPermissionMap = new ConcurrentHashMap<>(); - public static boolean requireVolumeAccessDialog(Activity activity, @NonNull String anyPath) { return StorageUtils.requireAccessPermission(anyPath) && getVolumeTreeUri(activity, anyPath) == null; } @@ -89,13 +87,6 @@ public class PermissionManager { runnable.run(); } - private static Uri getVolumeTreeUriFromUuid(String uuid) { - return DocumentsContract.buildTreeDocumentUri( - "com.android.externalstorage.documents", - uuid + ":" - ); - } - static class PendingPermissionHandler { String volumePath; Runnable onGranted; From de3528baab3211677de77116b18de06503761038 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 18 Jul 2020 23:55:01 +0900 Subject: [PATCH 3/4] (possible) optimization when locating entries --- lib/model/source/location.dart | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/model/source/location.dart b/lib/model/source/location.dart index c2ea53c8d..dfa0d7bea 100644 --- a/lib/model/source/location.dart +++ b/lib/model/source/location.dart @@ -4,6 +4,7 @@ import 'package:aves/model/metadata_db.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:tuple/tuple.dart'; mixin LocationMixin on SourceBase { static const _commitCountThreshold = 50; @@ -24,16 +25,34 @@ mixin LocationMixin on SourceBase { Future locateEntries() async { // final stopwatch = Stopwatch()..start(); - final todo = rawEntries.where((entry) => entry.hasGps && !entry.isLocated).toList(); + final byLocated = groupBy(rawEntries.where((entry) => entry.hasGps), (entry) => entry.isLocated); + final todo = byLocated[false] ?? []; if (todo.isEmpty) return; + // cache known locations to avoid querying the geocoder unless necessary + // measuring the time it takes to process ~3000 coordinates (with ~10% of duplicates) + // does not clearly show whether it is an actual optimization, + // as results vary wildly (durations in "min:sec"): + // - with no cache: 06:17, 08:36, 08:34 + // - with cache: 08:28, 05:42, 08:03, 05:58 + // anyway, in theory it should help! + final knownLocations = , AddressDetails>{}; + byLocated[true]?.forEach((entry) => knownLocations.putIfAbsent(entry.latLng, () => entry.addressDetails)); + var progressDone = 0; final progressTotal = todo.length; setProgress(done: progressDone, total: progressTotal); final newAddresses = []; await Future.forEach(todo, (entry) async { - await entry.locate(background: true); + if (knownLocations.containsKey(entry.latLng)) { + entry.addressDetails = knownLocations[entry.latLng]?.copyWith(contentId: entry.contentId); + } else { + await entry.locate(background: true); + // it is intended to insert `null` if the geocoder failed, + // so that we skip geocoding of following entries with the same coordinates + knownLocations[entry.latLng] = entry.addressDetails; + } if (entry.isLocated) { newAddresses.add(entry.addressDetails); if (newAddresses.length >= _commitCountThreshold) { From 51952f769934d9dca3e191f4c01991b25b6e9f6d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Sat, 18 Jul 2020 23:55:54 +0900 Subject: [PATCH 4/4] version bump --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 78efde26d..5965f3931 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,7 +11,7 @@ description: A new Flutter application. # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.9+10 +version: 1.0.10+11 # video_player (as of v0.10.8+2, backed by ExoPlayer): # - does not support content URIs (by default, but trivial by fork)