Merge branch 'develop'
This commit is contained in:
commit
0c0c8cceab
9 changed files with 175 additions and 111 deletions
27
.github/workflows/main.yml
vendored
27
.github/workflows/main.yml
vendored
|
@ -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
|
|
@ -30,7 +30,7 @@
|
|||
<!-- to access media with unredacted metadata with scoped storage (Android Q+) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
|
||||
<!-- TODO remove this permission once this issue is fixed:
|
||||
<!-- TODO TLAD remove this permission once this issue is fixed:
|
||||
https://github.com/flutter/flutter/issues/42349
|
||||
https://github.com/flutter/flutter/issues/42451
|
||||
-->
|
||||
|
|
|
@ -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<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList());
|
||||
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
|
||||
@Override
|
||||
|
|
|
@ -56,7 +56,7 @@ public abstract class ImageProvider {
|
|||
return Futures.immediateFailedFuture(new UnsupportedOperationException());
|
||||
}
|
||||
|
||||
public void moveMultiple(final Activity activity, Boolean copy, String destinationDir, List<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
|
||||
public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List<AvesImageEntry> entries, @NonNull final ImageOpCallback callback) {
|
||||
callback.onFailure(new UnsupportedOperationException());
|
||||
}
|
||||
|
||||
|
@ -66,12 +66,11 @@ public abstract class ImageProvider {
|
|||
return;
|
||||
}
|
||||
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> fields);
|
||||
|
||||
|
|
|
@ -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<AvesImageEntry> entries, @NonNull ImageOpCallback callback) {
|
||||
public void moveMultiple(final Activity activity, final Boolean copy, final String destinationDir, final List<AvesImageEntry> 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<String, Object> result = new HashMap<String, Object>() {{
|
||||
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<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> moveSingleByMediaStoreInsert(final Activity activity, final String sourcePath, final Uri sourceUri,
|
||||
final MediaStoreMoveDestination destination, final String mimeType, final boolean copy) {
|
||||
SettableFuture<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> moveSingleByTreeDocAndScan(final Activity activity, final String sourcePath, final Uri sourceUri, final String destinationDir, final DocumentFileCompat destinationDirDocFile, final String mimeType, boolean copy) {
|
||||
SettableFuture<Map<String, Object>> 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<String, Object> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<Integer, PendingPermissionHandler> 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;
|
||||
|
|
|
@ -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<void> locateEntries() async {
|
||||
// final stopwatch = Stopwatch()..start();
|
||||
final todo = rawEntries.where((entry) => entry.hasGps && !entry.isLocated).toList();
|
||||
final byLocated = groupBy<ImageEntry, bool>(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 = <Tuple2<double, double>, 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 = <AddressDetails>[];
|
||||
await Future.forEach<ImageEntry>(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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
1
whatsnew/whatsnew-en-US
Normal file
1
whatsnew/whatsnew-en-US
Normal file
|
@ -0,0 +1 @@
|
|||
Thanks for using Aves!
|
Loading…
Reference in a new issue