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:
|
with:
|
||||||
artifacts: "build/app/outputs/apk/release/*.apk,build/app/outputs/bundle/release/*.aab"
|
artifacts: "build/app/outputs/apk/release/*.apk,build/app/outputs/bundle/release/*.aab"
|
||||||
token: ${{ secrets.RELEASE_WORKFLOW_TOKEN }}
|
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+) -->
|
<!-- to access media with unredacted metadata with scoped storage (Android Q+) -->
|
||||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
<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/42349
|
||||||
https://github.com/flutter/flutter/issues/42451
|
https://github.com/flutter/flutter/issues/42451
|
||||||
-->
|
-->
|
||||||
|
|
|
@ -6,6 +6,7 @@ import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
@ -94,6 +95,10 @@ public class ImageOpStreamHandler implements EventChannel.StreamHandler {
|
||||||
String destinationDir = (String) argMap.get("destinationPath");
|
String destinationDir = (String) argMap.get("destinationPath");
|
||||||
if (copy == null || destinationDir == null) return;
|
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());
|
List<AvesImageEntry> entries = entryMapList.stream().map(AvesImageEntry::new).collect(Collectors.toList());
|
||||||
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
|
provider.moveMultiple(activity, copy, destinationDir, entries, new ImageProvider.ImageOpCallback() {
|
||||||
@Override
|
@Override
|
||||||
|
|
|
@ -56,7 +56,7 @@ public abstract class ImageProvider {
|
||||||
return Futures.immediateFailedFuture(new UnsupportedOperationException());
|
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());
|
callback.onFailure(new UnsupportedOperationException());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,12 +66,11 @@ public abstract class ImageProvider {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> newFields = new HashMap<>();
|
|
||||||
File oldFile = new File(oldPath);
|
File oldFile = new File(oldPath);
|
||||||
File newFile = new File(oldFile.getParent(), newFilename);
|
File newFile = new File(oldFile.getParent(), newFilename);
|
||||||
if (oldFile.equals(newFile)) {
|
if (oldFile.equals(newFile)) {
|
||||||
Log.w(LOG_TAG, "new name and old name are the same, path=" + oldPath);
|
Log.w(LOG_TAG, "new name and old name are the same, path=" + oldPath);
|
||||||
callback.onSuccess(newFields);
|
callback.onSuccess(new HashMap<>());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,40 +93,7 @@ public abstract class ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null);
|
MediaScannerConnection.scanFile(activity, new String[]{oldPath}, new String[]{mimeType}, null);
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{newFile.getPath()}, new String[]{mimeType}, (newPath, newUri) -> {
|
scanNewPath(activity, newFile.getPath(), mimeType, callback);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void rotate(final Activity activity, final String path, final Uri uri, final String mimeType, final boolean clockwise, final ImageOpCallback 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 {
|
public interface ImageOpCallback {
|
||||||
void onSuccess(Map<String, Object> fields);
|
void onSuccess(Map<String, Object> fields);
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,6 @@ import android.content.ContentUris;
|
||||||
import android.content.ContentValues;
|
import android.content.ContentValues;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
import android.media.MediaScannerConnection;
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
|
@ -17,6 +16,7 @@ import android.provider.MediaStore;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.RequiresApi;
|
||||||
|
|
||||||
import com.commonsware.cwac.document.DocumentFileCompat;
|
import com.commonsware.cwac.document.DocumentFileCompat;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
@ -275,7 +275,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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)) {
|
if (PermissionManager.requireVolumeAccessDialog(activity, destinationDir)) {
|
||||||
Runnable runnable = () -> moveMultiple(activity, copy, destinationDir, entries, callback);
|
Runnable runnable = () -> moveMultiple(activity, copy, destinationDir, entries, callback);
|
||||||
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, destinationDir, runnable));
|
new Handler(Looper.getMainLooper()).post(() -> PermissionManager.showVolumeAccessDialog(activity, destinationDir, runnable));
|
||||||
|
@ -288,7 +288,11 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
return;
|
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) {
|
for (AvesImageEntry entry : entries) {
|
||||||
Uri sourceUri = entry.uri;
|
Uri sourceUri = entry.uri;
|
||||||
|
@ -298,16 +302,16 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
Map<String, Object> result = new HashMap<String, Object>() {{
|
Map<String, Object> result = new HashMap<String, Object>() {{
|
||||||
put("uri", sourceUri.toString());
|
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 {
|
try {
|
||||||
ListenableFuture<Map<String, Object>> newFieldsFuture;
|
ListenableFuture<Map<String, Object>> newFieldsFuture;
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
// if (Build.VERSION.SDK_INT > Build.VERSION_CODES.Q) {
|
||||||
if (volumeName == null) {
|
// newFieldsFuture = moveSingleByMediaStoreInsert(activity, sourcePath, sourceUri, destination, mimeType, copy);
|
||||||
volumeName = getVolumeName(activity, destinationDir);
|
// } else {
|
||||||
}
|
|
||||||
newFieldsFuture = moveSingleByMediaStoreInsert(activity, sourcePath, sourceUri, destinationDir, volumeName, mimeType, copy);
|
|
||||||
} else {
|
|
||||||
newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
|
newFieldsFuture = moveSingleByTreeDocAndScan(activity, sourcePath, sourceUri, destinationDir, destinationDirDocFile, mimeType, copy);
|
||||||
}
|
// }
|
||||||
Map<String, Object> newFields = newFieldsFuture.get();
|
Map<String, Object> newFields = newFieldsFuture.get();
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("newFields", newFields);
|
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
|
// - 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?)
|
// - 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
|
// - 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();
|
SettableFuture<Map<String, Object>> future = SettableFuture.create();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String destinationPath = destinationDir + File.separator + new File(sourcePath).getName();
|
String displayName = new File(sourcePath).getName();
|
||||||
|
String destinationFilePath = destination.fullPath + displayName;
|
||||||
// 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(...)`
|
|
||||||
|
|
||||||
ContentValues contentValues = new ContentValues();
|
ContentValues contentValues = new ContentValues();
|
||||||
contentValues.put(MediaStore.MediaColumns.DATA, destinationPath);
|
contentValues.put(MediaStore.MediaColumns.DATA, destinationFilePath);
|
||||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
|
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)
|
// from API 29, changing MediaColumns.RELATIVE_PATH can move files on disk (same storage device)
|
||||||
// contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, "");
|
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, destination.relativePath);
|
||||||
// contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, "");
|
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, displayName);
|
||||||
Uri tableUrl = mimeType.startsWith(MimeTypes.VIDEO) ? MediaStore.Video.Media.getContentUri(volumeName) : MediaStore.Images.Media.getContentUri(volumeName);
|
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);
|
Uri destinationUri = activity.getContentResolver().insert(tableUrl, contentValues);
|
||||||
if (destinationUri == null) {
|
if (destinationUri == null) {
|
||||||
future.setException(new Exception("failed to insert row to content resolver"));
|
future.setException(new Exception("failed to insert row to content resolver"));
|
||||||
} else {
|
} else {
|
||||||
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
DocumentFileCompat sourceFile = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
||||||
DocumentFileCompat destination = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
DocumentFileCompat destinationFile = DocumentFileCompat.fromSingleUri(activity, destinationUri);
|
||||||
source.copyTo(destination);
|
sourceFile.copyTo(destinationFile);
|
||||||
|
|
||||||
boolean deletedSource = false;
|
boolean deletedSource = false;
|
||||||
if (!copy) {
|
if (!copy) {
|
||||||
|
@ -362,7 +369,7 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
Map<String, Object> newFields = new HashMap<>();
|
Map<String, Object> newFields = new HashMap<>();
|
||||||
newFields.put("uri", destinationUri.toString());
|
newFields.put("uri", destinationUri.toString());
|
||||||
newFields.put("contentId", ContentUris.parseId(destinationUri));
|
newFields.put("contentId", ContentUris.parseId(destinationUri));
|
||||||
newFields.put("path", destinationPath);
|
newFields.put("path", destinationFilePath);
|
||||||
newFields.put("deletedSource", deletedSource);
|
newFields.put("deletedSource", deletedSource);
|
||||||
future.set(newFields);
|
future.set(newFields);
|
||||||
}
|
}
|
||||||
|
@ -376,30 +383,33 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
|
|
||||||
// We can create an item via `DocumentFile.createFile()`, but:
|
// We can create an item via `DocumentFile.createFile()`, but:
|
||||||
// - we need to scan the file to get the Media Store content URI
|
// - 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) {
|
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();
|
SettableFuture<Map<String, Object>> future = SettableFuture.create();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO TLAD more robust `destinationPath`, as it could be broken:
|
String sourceFileName = new File(sourcePath).getName();
|
||||||
// - if a file with the same name already exists, and the name gets appended ` (1)`
|
String desiredNameWithoutExtension = sourceFileName.replaceFirst("[.][^.]+$", "");
|
||||||
// - 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;
|
|
||||||
|
|
||||||
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
|
||||||
// the file created from a `TreeDocumentFile` is also a `TreeDocumentFile`
|
// 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`
|
// but in order to open an output stream to it, we need to use a `SingleDocumentFile`
|
||||||
// through a document URI, not a tree URI
|
// 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());
|
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.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
|
||||||
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
|
// `DocumentsContract.copyDocument()` yields "Unsupported call: android:copyDocument"
|
||||||
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
|
// when used with entry URI as `sourceDocumentUri`, and destinationDirDocFile URI as `targetParentDocumentUri`
|
||||||
|
DocumentFileCompat source = DocumentFileCompat.fromSingleUri(activity, sourceUri);
|
||||||
source.copyTo(destinationDocFile);
|
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;
|
boolean deletedSource = false;
|
||||||
if (!copy) {
|
if (!copy) {
|
||||||
// delete original entry
|
// delete original entry
|
||||||
|
@ -412,34 +422,17 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean finalDeletedSource = deletedSource;
|
boolean finalDeletedSource = deletedSource;
|
||||||
MediaScannerConnection.scanFile(activity, new String[]{destinationPath}, new String[]{mimeType}, (newPath, newUri) -> {
|
scanNewPath(activity, destinationFullPath, mimeType, new ImageProvider.ImageOpCallback() {
|
||||||
Map<String, Object> newFields = new HashMap<>();
|
@Override
|
||||||
if (newUri != null) {
|
public void onSuccess(Map<String, Object> newFields) {
|
||||||
// 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);
|
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);
|
future.set(newFields);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Throwable throwable) {
|
||||||
|
future.setException(throwable);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e);
|
Log.e(LOG_TAG, "failed to " + (copy ? "copy" : "move") + " entry", e);
|
||||||
|
@ -456,4 +449,18 @@ public class MediaStoreImageProvider extends ImageProvider {
|
||||||
public interface NewEntryChecker {
|
public interface NewEntryChecker {
|
||||||
boolean where(int contentId, int dateModifiedSecs);
|
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.Build;
|
||||||
import android.os.storage.StorageManager;
|
import android.os.storage.StorageManager;
|
||||||
import android.os.storage.StorageVolume;
|
import android.os.storage.StorageVolume;
|
||||||
import android.provider.DocumentsContract;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
@ -28,7 +27,6 @@ public class PermissionManager {
|
||||||
// permission request code to pending runnable
|
// permission request code to pending runnable
|
||||||
private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>();
|
private static ConcurrentHashMap<Integer, PendingPermissionHandler> pendingPermissionMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
|
||||||
public static boolean requireVolumeAccessDialog(Activity activity, @NonNull String anyPath) {
|
public static boolean requireVolumeAccessDialog(Activity activity, @NonNull String anyPath) {
|
||||||
return StorageUtils.requireAccessPermission(anyPath) && getVolumeTreeUri(activity, anyPath) == null;
|
return StorageUtils.requireAccessPermission(anyPath) && getVolumeTreeUri(activity, anyPath) == null;
|
||||||
}
|
}
|
||||||
|
@ -89,13 +87,6 @@ public class PermissionManager {
|
||||||
runnable.run();
|
runnable.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Uri getVolumeTreeUriFromUuid(String uuid) {
|
|
||||||
return DocumentsContract.buildTreeDocumentUri(
|
|
||||||
"com.android.externalstorage.documents",
|
|
||||||
uuid + ":"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static class PendingPermissionHandler {
|
static class PendingPermissionHandler {
|
||||||
String volumePath;
|
String volumePath;
|
||||||
Runnable onGranted;
|
Runnable onGranted;
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
mixin LocationMixin on SourceBase {
|
mixin LocationMixin on SourceBase {
|
||||||
static const _commitCountThreshold = 50;
|
static const _commitCountThreshold = 50;
|
||||||
|
@ -24,16 +25,34 @@ mixin LocationMixin on SourceBase {
|
||||||
|
|
||||||
Future<void> locateEntries() async {
|
Future<void> locateEntries() async {
|
||||||
// final stopwatch = Stopwatch()..start();
|
// 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;
|
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;
|
var progressDone = 0;
|
||||||
final progressTotal = todo.length;
|
final progressTotal = todo.length;
|
||||||
setProgress(done: progressDone, total: progressTotal);
|
setProgress(done: progressDone, total: progressTotal);
|
||||||
|
|
||||||
final newAddresses = <AddressDetails>[];
|
final newAddresses = <AddressDetails>[];
|
||||||
await Future.forEach<ImageEntry>(todo, (entry) async {
|
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);
|
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) {
|
if (entry.isLocated) {
|
||||||
newAddresses.add(entry.addressDetails);
|
newAddresses.add(entry.addressDetails);
|
||||||
if (newAddresses.length >= _commitCountThreshold) {
|
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.
|
# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion.
|
||||||
# Read more about iOS versioning at
|
# Read more about iOS versioning at
|
||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# 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):
|
# video_player (as of v0.10.8+2, backed by ExoPlayer):
|
||||||
# - does not support content URIs (by default, but trivial by fork)
|
# - 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