Merge branch 'develop'

This commit is contained in:
Thibault Deckers 2020-07-18 23:56:16 +09:00
commit 0c0c8cceab
9 changed files with 175 additions and 111 deletions

View file

@ -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

View file

@ -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
-->

View file

@ -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

View file

@ -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);

View file

@ -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 {
// 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);
scanNewPath(activity, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() {
@Override
public void onSuccess(Map<String, Object> newFields) {
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 {
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;
}
}
}

View file

@ -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;

View file

@ -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 {
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) {

View file

@ -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
View file

@ -0,0 +1 @@
Thanks for using Aves!