media store fetch by stream handler, collection source split in mixins

This commit is contained in:
Thibault Deckers 2020-06-11 18:06:30 +09:00
parent 049840bd73
commit b170ce0492
49 changed files with 633 additions and 579 deletions

View file

@ -10,21 +10,20 @@ import java.util.Map;
import java.util.Objects;
import app.loup.streams_channel.StreamsChannel;
import deckers.thibault.aves.channelhandlers.AppAdapterHandler;
import deckers.thibault.aves.channelhandlers.ImageByteStreamHandler;
import deckers.thibault.aves.channelhandlers.ImageFileHandler;
import deckers.thibault.aves.channelhandlers.ImageOpStreamHandler;
import deckers.thibault.aves.channelhandlers.MediaStoreStreamHandler;
import deckers.thibault.aves.channelhandlers.MetadataHandler;
import deckers.thibault.aves.channelhandlers.StorageAccessStreamHandler;
import deckers.thibault.aves.channelhandlers.StorageHandler;
import deckers.thibault.aves.channel.calls.AppAdapterHandler;
import deckers.thibault.aves.channel.calls.ImageFileHandler;
import deckers.thibault.aves.channel.calls.MetadataHandler;
import deckers.thibault.aves.channel.calls.StorageHandler;
import deckers.thibault.aves.channel.streams.ImageByteStreamHandler;
import deckers.thibault.aves.channel.streams.ImageOpStreamHandler;
import deckers.thibault.aves.channel.streams.MediaStoreStreamHandler;
import deckers.thibault.aves.channel.streams.StorageAccessStreamHandler;
import deckers.thibault.aves.utils.Constants;
import deckers.thibault.aves.utils.Env;
import deckers.thibault.aves.utils.PermissionManager;
import deckers.thibault.aves.utils.Utils;
import io.flutter.embedding.android.FlutterActivity;
import io.flutter.plugin.common.BinaryMessenger;
import io.flutter.plugin.common.EventChannel;
import io.flutter.plugin.common.MethodChannel;
public class MainActivity extends FlutterActivity {
@ -40,23 +39,17 @@ public class MainActivity extends FlutterActivity {
handleIntent(getIntent());
MediaStoreStreamHandler mediaStoreStreamHandler = new MediaStoreStreamHandler();
BinaryMessenger messenger = Objects.requireNonNull(getFlutterEngine()).getDartExecutor().getBinaryMessenger();
new MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(new StorageHandler(this));
new MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(new AppAdapterHandler(this));
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this, mediaStoreStreamHandler));
new MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(new ImageFileHandler(this));
new MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(new MetadataHandler(this));
new EventChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandler(mediaStoreStreamHandler);
new MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(new StorageHandler(this));
final StreamsChannel fileAccessStreamChannel = new StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL);
fileAccessStreamChannel.setStreamHandlerFactory(arguments -> new StorageAccessStreamHandler(this, arguments));
final StreamsChannel imageByteStreamChannel = new StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL);
imageByteStreamChannel.setStreamHandlerFactory(arguments -> new ImageByteStreamHandler(this, arguments));
final StreamsChannel imageOpStreamChannel = new StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL);
imageOpStreamChannel.setStreamHandlerFactory(arguments -> new ImageOpStreamHandler(this, arguments));
new StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new ImageByteStreamHandler(this, args));
new StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new ImageOpStreamHandler(this, args));
new StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new MediaStoreStreamHandler(this));
new StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory(args -> new StorageAccessStreamHandler(this, args));
new MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler(
(call, result) -> {

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.calls;
import android.content.ContentResolver;
import android.content.Context;

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.calls;
import android.annotation.SuppressLint;
import android.app.Activity;

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.calls;
import android.app.Activity;
import android.net.Uri;
@ -22,11 +22,9 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
private Activity activity;
private float density;
private MediaStoreStreamHandler mediaStoreStreamHandler;
public ImageFileHandler(Activity activity, MediaStoreStreamHandler mediaStoreStreamHandler) {
public ImageFileHandler(Activity activity) {
this.activity = activity;
this.mediaStoreStreamHandler = mediaStoreStreamHandler;
}
public float getDensity() {
@ -39,14 +37,6 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
@Override
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "getImageEntries":
new Thread(() -> {
String sortBy = call.argument("sort");
String groupBy = call.argument("group");
mediaStoreStreamHandler.fetchAll(activity, sortBy, groupBy);
}).start();
result.success(null);
break;
case "getImageEntry":
new Thread(() -> getImageEntry(call, new MethodResultWrapper(result))).start();
break;
@ -70,7 +60,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
private void getThumbnail(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map entryMap = call.argument("entry");
Map<String, Object> entryMap = call.argument("entry");
Double widthDip = call.argument("widthDip");
Double heightDip = call.argument("heightDip");
Double defaultSizeDip = call.argument("defaultSizeDip");
@ -118,7 +108,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
private void rename(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map entryMap = call.argument("entry");
Map<String, Object> entryMap = call.argument("entry");
String newName = call.argument("newName");
if (entryMap == null || newName == null) {
result.error("rename-args", "failed because of missing arguments", null);
@ -147,7 +137,7 @@ public class ImageFileHandler implements MethodChannel.MethodCallHandler {
}
private void rotate(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
Map entryMap = call.argument("entry");
Map<String, Object> entryMap = call.argument("entry");
Boolean clockwise = call.argument("clockwise");
if (entryMap == null || clockwise == null) {
result.error("rotate-args", "failed because of missing arguments", null);

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.calls;
import android.content.ContentUris;
import android.content.Context;

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.calls;
import android.os.Handler;
import android.os.Looper;

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.calls;
import android.app.Activity;
import android.os.Build;
@ -32,7 +32,7 @@ public class StorageHandler implements MethodChannel.MethodCallHandler {
public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
switch (call.method) {
case "getStorageVolumes": {
List<Map<String, Object>> volumes = null;
List<Map<String, Object>> volumes;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
volumes = getStorageVolumes();
} else {

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.streams;
import android.app.Activity;
import android.content.ContentResolver;
@ -33,10 +33,11 @@ public class ImageByteStreamHandler implements EventChannel.StreamHandler {
private EventChannel.EventSink eventSink;
private Handler handler;
@SuppressWarnings("unchecked")
public ImageByteStreamHandler(Activity activity, Object arguments) {
this.activity = activity;
if (arguments instanceof Map) {
Map argMap = (Map) arguments;
Map<String, Object> argMap = (Map<String, Object>) arguments;
this.mimeType = (String) argMap.get("mimeType");
this.uri = Uri.parse((String) argMap.get("uri"));
}

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.streams;
import android.app.Activity;
import android.net.Uri;

View file

@ -0,0 +1,47 @@
package deckers.thibault.aves.channel.streams;
import android.app.Activity;
import android.os.Handler;
import android.os.Looper;
import java.util.Map;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
import io.flutter.plugin.common.EventChannel;
public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/mediastorestream";
private Activity activity;
private EventChannel.EventSink eventSink;
private Handler handler;
public MediaStoreStreamHandler(Activity activity) {
this.activity = activity;
}
@Override
public void onListen(Object args, final EventChannel.EventSink eventSink) {
this.eventSink = eventSink;
this.handler = new Handler(Looper.getMainLooper());
new Thread(this::fetchAll).start();
}
@Override
public void onCancel(Object args) {
// nothing
}
private void success(final Map<String, Object> result) {
handler.post(() -> eventSink.success(result));
}
private void endOfStream() {
handler.post(() -> eventSink.endOfStream());
}
void fetchAll() {
new MediaStoreImageProvider().fetchAll(activity, this::success); // 350ms
endOfStream();
}
}

View file

@ -1,4 +1,4 @@
package deckers.thibault.aves.channelhandlers;
package deckers.thibault.aves.channel.streams;
import android.app.Activity;
import android.os.Handler;
@ -19,10 +19,11 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
private Handler handler;
private String volumePath;
@SuppressWarnings("unchecked")
public StorageAccessStreamHandler(Activity activity, Object arguments) {
this.activity = activity;
if (arguments instanceof Map) {
Map argMap = (Map) arguments;
Map<String, Object> argMap = (Map<String, Object>) arguments;
this.volumePath = (String) argMap.get("path");
}
}
@ -45,10 +46,6 @@ public class StorageAccessStreamHandler implements EventChannel.StreamHandler {
endOfStream();
}
private void error(final String errorCode, final String errorMessage, final Object errorDetails) {
handler.post(() -> eventSink.error(errorCode, errorMessage, errorDetails));
}
private void endOfStream() {
handler.post(() -> eventSink.endOfStream());
}

View file

@ -1,30 +0,0 @@
package deckers.thibault.aves.channelhandlers;
import android.app.Activity;
import android.os.Handler;
import android.os.Looper;
import deckers.thibault.aves.model.provider.MediaStoreImageProvider;
import io.flutter.plugin.common.EventChannel;
public class MediaStoreStreamHandler implements EventChannel.StreamHandler {
public static final String CHANNEL = "deckers.thibault/aves/mediastore";
private EventChannel.EventSink eventSink;
@Override
public void onListen(Object args, final EventChannel.EventSink events) {
eventSink = events;
}
@Override
public void onCancel(Object args) {
// nothing
}
void fetchAll(Activity activity, String sortBy, String groupBy) {
Handler handler = new Handler(Looper.getMainLooper());
new MediaStoreImageProvider().fetchAll(activity, sortBy, groupBy, (entry) -> handler.post(() -> eventSink.success(entry))); // 350ms
handler.post(() -> eventSink.endOfStream());
}
}

View file

@ -68,35 +68,12 @@ public class MediaStoreImageProvider extends ImageProvider {
MediaStore.Video.Media.ORIENTATION,
} : new String[0]).flatMap(Stream::of).toArray(String[]::new);
public void fetchAll(Activity activity, final String sortBy, final String groupBy, NewEntryHandler newEntryHandler) {
public void fetchAll(Activity activity, NewEntryHandler newEntryHandler) {
String orderBy;
switch (sortBy) {
case "size":
orderBy = MediaStore.MediaColumns.SIZE + " DESC";
break;
case "name":
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
orderBy = MediaStore.MediaColumns.RELATIVE_PATH + ", " + MediaStore.MediaColumns.BUCKET_DISPLAY_NAME + ", " + MediaStore.MediaColumns.DISPLAY_NAME;
} else {
orderBy = MediaStore.MediaColumns.DATA;
}
break;
default:
case "date":
switch (groupBy) {
case "album":
// TODO TLAD find album order first
case "month":
case "day":
default:
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC";
} else {
orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC";
}
break;
}
break;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
orderBy = MediaStore.MediaColumns.DATE_TAKEN + " DESC";
} else {
orderBy = MediaStore.MediaColumns.DATE_MODIFIED + " DESC";
}
fetchFrom(activity, newEntryHandler, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, IMAGE_PROJECTION, orderBy);

View file

@ -21,7 +21,6 @@ import com.google.common.base.Splitter;
import com.google.common.collect.Lists;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;

View file

@ -1,20 +1,10 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/viewer_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:aves/widgets/welcome.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:aves/widgets/welcome_page.dart';
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pedantic/pedantic.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:screen/screen.dart';
void main() {
// HttpClient.enableTimelineLogging = true; // enable network traffic logging
@ -79,100 +69,3 @@ class _AvesAppState extends State<AvesApp> {
);
}
}
class HomePage extends StatefulWidget {
const HomePage();
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
MediaStoreSource _mediaStore;
ImageEntry _viewerEntry;
Future<void> _appSetup;
@override
void initState() {
super.initState();
_appSetup = _setup();
imageCache.maximumSizeBytes = 512 * (1 << 20);
Screen.keepOn(true);
}
Future<void> _setup() async {
debugPrint('$runtimeType _setup');
// TODO reduce permission check time
final permissions = await [
Permission.storage,
// to access media with unredacted metadata with scoped storage (Android 10+)
Permission.accessMediaLocation,
].request(); // 350ms
if (permissions[Permission.storage] != PermissionStatus.granted) {
unawaited(SystemNavigator.pop());
return;
}
// TODO notify when icons are ready for drawer and section header refresh
await androidFileUtils.init(); // 170ms
final intentData = await ViewerService.getIntentData();
if (intentData != null) {
final action = intentData['action'];
switch (action) {
case 'view':
AvesApp.mode = AppMode.view;
_viewerEntry = await _initViewerEntry(
uri: intentData['uri'],
mimeType: intentData['mimeType'],
);
if (_viewerEntry == null) {
// fallback to default mode when we fail to retrieve the entry
AvesApp.mode = AppMode.main;
}
break;
case 'pick':
AvesApp.mode = AppMode.pick;
break;
}
}
if (AvesApp.mode != AppMode.view) {
_mediaStore = MediaStoreSource();
unawaited(_mediaStore.fetch());
}
}
Future<ImageEntry> _initViewerEntry({@required String uri, @required String mimeType}) async {
final entry = await ImageFileService.getImageEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for geolocation and video rotation
await entry.catalog();
unawaited(entry.locate());
}
return entry;
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _appSetup,
builder: (context, AsyncSnapshot<void> snapshot) {
if (snapshot.hasError) return const Icon(AIcons.error);
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
debugPrint('$runtimeType app setup future complete');
if (AvesApp.mode == AppMode.view) {
return SingleFullscreenPage(entry: _viewerEntry);
}
if (_mediaStore != null) {
return CollectionPage(CollectionLens(
source: _mediaStore.source,
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
));
}
return const SizedBox.shrink();
});
}
}

View file

@ -1,284 +0,0 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart';
class CollectionSource {
final List<ImageEntry> _rawEntries;
final Set<String> _folderPaths = {};
final Map<CollectionFilter, int> _filterEntryCountMap = {};
final EventBus _eventBus = EventBus();
List<String> sortedAlbums = List.unmodifiable([]);
List<String> sortedCountries = List.unmodifiable([]);
List<String> sortedPlaces = List.unmodifiable([]);
List<String> sortedTags = List.unmodifiable([]);
ValueNotifier<SourceState> stateNotifier = ValueNotifier<SourceState>(SourceState.ready);
List<ImageEntry> get entries => List.unmodifiable(_rawEntries);
EventBus get eventBus => _eventBus;
int get albumCount => sortedAlbums.length;
int get tagCount => sortedTags.length;
CollectionSource({
List<ImageEntry> entries,
}) : _rawEntries = entries ?? [];
final List<DateMetadata> savedDates = [];
Future<void> loadDates() async {
final stopwatch = Stopwatch()..start();
savedDates.addAll(await metadataDb.loadDates());
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${savedDates.length} saved entries');
}
Future<void> loadCatalogMetadata() async {
final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadMetadataEntries();
_rawEntries.forEach((entry) {
final contentId = entry.contentId;
entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null);
});
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} saved entries');
onCatalogMetadataChanged();
}
Future<void> loadAddresses() async {
final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadAddresses();
_rawEntries.forEach((entry) {
final contentId = entry.contentId;
entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null);
});
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} saved entries');
onAddressMetadataChanged();
}
Future<void> catalogEntries() async {
final stopwatch = Stopwatch()..start();
final uncataloguedEntries = _rawEntries.where((entry) => !entry.isCatalogued).toList();
if (uncataloguedEntries.isEmpty) return;
final newMetadata = <CatalogMetadata>[];
await Future.forEach<ImageEntry>(uncataloguedEntries, (entry) async {
await entry.catalog();
if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata);
if (newMetadata.length >= 500) {
await metadataDb.saveMetadata(List.unmodifiable(newMetadata));
newMetadata.clear();
}
}
});
await metadataDb.saveMetadata(List.unmodifiable(newMetadata));
onCatalogMetadataChanged();
debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s with ${newMetadata.length} new entries');
}
Future<void> locateEntries() async {
final stopwatch = Stopwatch()..start();
final unlocatedEntries = _rawEntries.where((entry) => entry.hasGps && !entry.isLocated).toList();
final newAddresses = <AddressDetails>[];
await Future.forEach<ImageEntry>(unlocatedEntries, (entry) async {
await entry.locate();
if (entry.isLocated) {
newAddresses.add(entry.addressDetails);
if (newAddresses.length >= 50) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
newAddresses.clear();
}
}
});
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
onAddressMetadataChanged();
debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inMilliseconds}ms');
}
void onCatalogMetadataChanged() {
updateTags();
eventBus.fire(CatalogMetadataChangedEvent());
}
void onAddressMetadataChanged() {
updateLocations();
eventBus.fire(AddressMetadataChangedEvent());
}
void updateAlbums() {
final sorted = _folderPaths.toList()
..sort((a, b) {
final ua = getUniqueAlbumName(a);
final ub = getUniqueAlbumName(b);
return compareAsciiUpperCase(ua, ub);
});
sortedAlbums = List.unmodifiable(sorted);
_filterEntryCountMap.clear();
eventBus.fire(AlbumsChangedEvent());
}
void updateTags() {
final tags = _rawEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
sortedTags = List.unmodifiable(tags);
_filterEntryCountMap.clear();
eventBus.fire(TagsChangedEvent());
}
void updateLocations() {
final locations = _rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails);
final lister = (String Function(AddressDetails a) f) => List<String>.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
sortedCountries = lister((address) => '${address.countryName};${address.countryCode}');
sortedPlaces = lister((address) => address.place);
_filterEntryCountMap.clear();
eventBus.fire(LocationsChangedEvent());
}
void addAll(Iterable<ImageEntry> entries) {
entries.forEach((entry) {
final contentId = entry.contentId;
entry.catalogDateMillis = savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis;
});
_rawEntries.addAll(entries);
_folderPaths.addAll(_rawEntries.map((entry) => entry.directory).toSet());
_filterEntryCountMap.clear();
eventBus.fire(const EntryAddedEvent());
}
void removeEntries(Iterable<ImageEntry> entries) async {
entries.forEach((entry) => entry.removeFromFavourites());
_rawEntries.removeWhere(entries.contains);
_cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
_filterEntryCountMap.clear();
eventBus.fire(EntryRemovedEvent(entries));
}
void applyMove({
@required Iterable<ImageEntry> entries,
@required Set<String> fromAlbums,
@required String toAlbum,
@required bool copy,
}) {
if (copy) {
addAll(entries);
} else {
_cleanEmptyAlbums(fromAlbums);
_folderPaths.add(toAlbum);
}
updateAlbums();
_filterEntryCountMap.clear();
eventBus.fire(EntryMovedEvent(entries));
}
void _cleanEmptyAlbums(Set<String> albums) {
final emptyAlbums = albums.where(_isEmptyAlbum);
if (emptyAlbums.isNotEmpty) {
_folderPaths.removeAll(emptyAlbums);
updateAlbums();
}
}
bool _isEmptyAlbum(String album) => !_rawEntries.any((entry) => entry.directory == album);
String getUniqueAlbumName(String album) {
final volumeRoot = androidFileUtils.getStorageVolume(album)?.path ?? '';
final otherAlbums = _folderPaths.where((item) => item != album && item.startsWith(volumeRoot));
final parts = album.split(separator);
var partCount = 0;
String testName;
do {
testName = separator + parts.skip(parts.length - ++partCount).join(separator);
} while (otherAlbums.any((item) => item.endsWith(testName)));
return parts.skip(parts.length - partCount).join(separator);
}
List<ImageEntry> get _sortedEntriesForFilterList => CollectionLens(
source: this,
groupFactor: GroupFactor.month,
sortFactor: SortFactor.date,
).sortedEntries;
Map<String, ImageEntry> getAlbumEntries() {
final entries = _sortedEntriesForFilterList;
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in sortedAlbums) {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular:
regularAlbums.add(album);
break;
case AlbumType.app:
appAlbums.add(album);
break;
default:
specialAlbums.add(album);
break;
}
}
return Map.fromEntries([...specialAlbums, ...appAlbums, ...regularAlbums].map((tag) => MapEntry(
tag,
entries.firstWhere((entry) => entry.directory == tag, orElse: () => null),
)));
}
Map<String, ImageEntry> getCountryEntries() {
final locatedEntries = _sortedEntriesForFilterList.where((entry) => entry.isLocated);
return Map.fromEntries(sortedCountries.map((countryNameAndCode) {
final split = countryNameAndCode.split(';');
ImageEntry entry;
if (split.length > 1) {
final countryCode = split[1];
entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null);
}
return MapEntry(countryNameAndCode, entry);
}));
}
Map<String, ImageEntry> getTagEntries() {
final entries = _sortedEntriesForFilterList;
return Map.fromEntries(sortedTags.map((tag) => MapEntry(
tag,
entries.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
)));
}
int count(CollectionFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
}
}
enum SourceState { loading, cataloguing, locating, ready }
class AddressMetadataChangedEvent {}
class CatalogMetadataChangedEvent {}
class AlbumsChangedEvent {}
class LocationsChangedEvent {}
class TagsChangedEvent {}
class EntryAddedEvent {
final ImageEntry entry;
const EntryAddedEvent([this.entry]);
}
class EntryRemovedEvent {
final Iterable<ImageEntry> entries;
const EntryRemovedEvent(this.entries);
}
class EntryMovedEvent {
final Iterable<ImageEntry> entries;
const EntryMovedEvent(this.entries);
}

View file

@ -1,4 +1,4 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';

View file

@ -0,0 +1,71 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:collection/collection.dart';
import 'package:path/path.dart';
mixin AlbumMixin on SourceBase {
final Set<String> _folderPaths = {};
List<String> sortedAlbums = List.unmodifiable([]);
void updateAlbums() {
final sorted = _folderPaths.toList()
..sort((a, b) {
final ua = getUniqueAlbumName(a);
final ub = getUniqueAlbumName(b);
return compareAsciiUpperCase(ua, ub);
});
sortedAlbums = List.unmodifiable(sorted);
invalidateFilterEntryCounts();
eventBus.fire(AlbumsChangedEvent());
}
String getUniqueAlbumName(String album) {
final volumeRoot = androidFileUtils.getStorageVolume(album)?.path ?? '';
final otherAlbums = _folderPaths.where((item) => item != album && item.startsWith(volumeRoot));
final parts = album.split(separator);
var partCount = 0;
String testName;
do {
testName = separator + parts.skip(parts.length - ++partCount).join(separator);
} while (otherAlbums.any((item) => item.endsWith(testName)));
return parts.skip(parts.length - partCount).join(separator);
}
Map<String, ImageEntry> getAlbumEntries() {
final entries = sortedEntriesForFilterList;
final regularAlbums = <String>[], appAlbums = <String>[], specialAlbums = <String>[];
for (var album in sortedAlbums) {
switch (androidFileUtils.getAlbumType(album)) {
case AlbumType.regular:
regularAlbums.add(album);
break;
case AlbumType.app:
appAlbums.add(album);
break;
default:
specialAlbums.add(album);
break;
}
}
return Map.fromEntries([...specialAlbums, ...appAlbums, ...regularAlbums].map((album) => MapEntry(
album,
entries.firstWhere((entry) => entry.directory == album, orElse: () => null),
)));
}
void addFolderPath(Iterable<String> albums) => _folderPaths.addAll(albums);
void cleanEmptyAlbums([Set<String> albums]) {
final emptyAlbums = (albums ?? _folderPaths).where(_isEmptyAlbum).toList();
if (emptyAlbums.isNotEmpty) {
_folderPaths.removeAll(emptyAlbums);
updateAlbums();
}
}
bool _isEmptyAlbum(String album) => !rawEntries.any((entry) => entry.directory == album);
}
class AlbumsChangedEvent {}

View file

@ -1,11 +1,12 @@
import 'dart:async';
import 'dart:collection';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/change_notifier.dart';
import 'package:collection/collection.dart';
@ -125,7 +126,7 @@ class CollectionLens with ChangeNotifier, CollectionActivityMixin, CollectionSel
}
void _applyFilters() {
final rawEntries = source.entries;
final rawEntries = source.rawEntries;
_filteredEntries = List.of(filters.isEmpty ? rawEntries : rawEntries.where((entry) => filters.fold(true, (prev, filter) => prev && filter.filter(entry))));
}

View file

@ -0,0 +1,115 @@
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
mixin SourceBase {
final List<ImageEntry> _rawEntries = [];
List<ImageEntry> get rawEntries => List.unmodifiable(_rawEntries);
final EventBus _eventBus = EventBus();
EventBus get eventBus => _eventBus;
List<ImageEntry> get sortedEntriesForFilterList;
final Map<CollectionFilter, int> _filterEntryCountMap = {};
void invalidateFilterEntryCounts() => _filterEntryCountMap.clear();
}
class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagMixin {
@override
List<ImageEntry> get sortedEntriesForFilterList => CollectionLens(
source: this,
groupFactor: GroupFactor.month,
sortFactor: SortFactor.date,
).sortedEntries;
ValueNotifier<SourceState> stateNotifier = ValueNotifier<SourceState>(SourceState.ready);
List<DateMetadata> _savedDates;
Future<void> loadDates() async {
final stopwatch = Stopwatch()..start();
_savedDates = List.unmodifiable(await metadataDb.loadDates());
debugPrint('$runtimeType loadDates complete in ${stopwatch.elapsed.inMilliseconds}ms for ${_savedDates.length} saved entries');
}
void addAll(Iterable<ImageEntry> entries) {
entries.forEach((entry) {
final contentId = entry.contentId;
entry.catalogDateMillis = _savedDates.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null)?.dateMillis;
});
_rawEntries.addAll(entries);
addFolderPath(_rawEntries.map((entry) => entry.directory));
invalidateFilterEntryCounts();
eventBus.fire(const EntryAddedEvent());
}
void removeEntries(Iterable<ImageEntry> entries) {
entries.forEach((entry) => entry.removeFromFavourites());
_rawEntries.removeWhere(entries.contains);
// TODO TLAD invalidate locations/tags, like cleaning albums
cleanEmptyAlbums(entries.map((entry) => entry.directory).toSet());
invalidateFilterEntryCounts();
eventBus.fire(EntryRemovedEvent(entries));
}
void clearEntries() {
_rawEntries.clear();
cleanEmptyAlbums();
updateAlbums();
updateLocations();
updateTags();
invalidateFilterEntryCounts();
}
void applyMove({
@required Iterable<ImageEntry> entries,
@required Set<String> fromAlbums,
@required String toAlbum,
@required bool copy,
}) {
if (copy) {
addAll(entries);
} else {
cleanEmptyAlbums(fromAlbums);
addFolderPath({toAlbum});
}
updateAlbums();
invalidateFilterEntryCounts();
eventBus.fire(EntryMovedEvent(entries));
}
int count(CollectionFilter filter) {
return _filterEntryCountMap.putIfAbsent(filter, () => _rawEntries.where((entry) => filter.filter(entry)).length);
}
}
enum SourceState { loading, cataloguing, locating, ready }
class EntryAddedEvent {
final ImageEntry entry;
const EntryAddedEvent([this.entry]);
}
class EntryRemovedEvent {
final Iterable<ImageEntry> entries;
const EntryRemovedEvent(this.entries);
}
class EntryMovedEvent {
final Iterable<ImageEntry> entries;
const EntryMovedEvent(this.entries);
}

View file

@ -0,0 +1,73 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
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';
mixin LocationMixin on SourceBase {
List<String> sortedCountries = List.unmodifiable([]);
List<String> sortedPlaces = List.unmodifiable([]);
Future<void> loadAddresses() async {
final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadAddresses();
rawEntries.forEach((entry) {
final contentId = entry.contentId;
entry.addressDetails = saved.firstWhere((address) => address.contentId == contentId, orElse: () => null);
});
debugPrint('$runtimeType loadAddresses complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} saved entries');
onAddressMetadataChanged();
}
Future<void> locateEntries() async {
final stopwatch = Stopwatch()..start();
final unlocatedEntries = rawEntries.where((entry) => entry.hasGps && !entry.isLocated).toList();
final newAddresses = <AddressDetails>[];
await Future.forEach<ImageEntry>(unlocatedEntries, (entry) async {
await entry.locate();
if (entry.isLocated) {
newAddresses.add(entry.addressDetails);
if (newAddresses.length >= 50) {
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
newAddresses.clear();
}
}
});
await metadataDb.saveAddresses(List.unmodifiable(newAddresses));
onAddressMetadataChanged();
debugPrint('$runtimeType locateEntries complete in ${stopwatch.elapsed.inMilliseconds}ms');
}
void onAddressMetadataChanged() {
updateLocations();
eventBus.fire(AddressMetadataChangedEvent());
}
void updateLocations() {
final locations = rawEntries.where((entry) => entry.isLocated).map((entry) => entry.addressDetails).toList();
final lister = (String Function(AddressDetails a) f) => List<String>.unmodifiable(locations.map(f).where((s) => s != null && s.isNotEmpty).toSet().toList()..sort(compareAsciiUpperCase));
sortedCountries = lister((address) => '${address.countryName};${address.countryCode}');
sortedPlaces = lister((address) => address.place);
invalidateFilterEntryCounts();
eventBus.fire(LocationsChangedEvent());
}
Map<String, ImageEntry> getCountryEntries() {
final locatedEntries = sortedEntriesForFilterList.where((entry) => entry.isLocated);
return Map.fromEntries(sortedCountries.map((countryNameAndCode) {
final split = countryNameAndCode.split(';');
ImageEntry entry;
if (split.length > 1) {
final countryCode = split[1];
entry = locatedEntries.firstWhere((entry) => entry.addressDetails.countryCode == countryCode, orElse: () => null);
}
return MapEntry(countryNameAndCode, entry);
}));
}
}
class AddressMetadataChangedEvent {}
class LocationsChangedEvent {}

66
lib/model/source/tag.dart Normal file
View file

@ -0,0 +1,66 @@
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
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';
mixin TagMixin on SourceBase {
List<String> sortedTags = List.unmodifiable([]);
Future<void> loadCatalogMetadata() async {
final stopwatch = Stopwatch()..start();
final saved = await metadataDb.loadMetadataEntries();
rawEntries.forEach((entry) {
final contentId = entry.contentId;
entry.catalogMetadata = saved.firstWhere((metadata) => metadata.contentId == contentId, orElse: () => null);
});
debugPrint('$runtimeType loadCatalogMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${saved.length} saved entries');
onCatalogMetadataChanged();
}
Future<void> catalogEntries() async {
final stopwatch = Stopwatch()..start();
final uncataloguedEntries = rawEntries.where((entry) => !entry.isCatalogued).toList();
if (uncataloguedEntries.isEmpty) return;
final newMetadata = <CatalogMetadata>[];
await Future.forEach<ImageEntry>(uncataloguedEntries, (entry) async {
await entry.catalog();
if (entry.isCatalogued) {
newMetadata.add(entry.catalogMetadata);
if (newMetadata.length >= 500) {
await metadataDb.saveMetadata(List.unmodifiable(newMetadata));
newMetadata.clear();
}
}
});
await metadataDb.saveMetadata(List.unmodifiable(newMetadata));
onCatalogMetadataChanged();
debugPrint('$runtimeType catalogEntries complete in ${stopwatch.elapsed.inSeconds}s with ${newMetadata.length} new entries');
}
void onCatalogMetadataChanged() {
updateTags();
eventBus.fire(CatalogMetadataChangedEvent());
}
void updateTags() {
final tags = rawEntries.expand((entry) => entry.xmpSubjects).toSet().toList()..sort(compareAsciiUpperCase);
sortedTags = List.unmodifiable(tags);
invalidateFilterEntryCounts();
eventBus.fire(TagsChangedEvent());
}
Map<String, ImageEntry> getTagEntries() {
final entries = sortedEntriesForFilterList;
return Map.fromEntries(sortedTags.map((tag) => MapEntry(
tag,
entries.firstWhere((entry) => entry.xmpSubjects.contains(tag), orElse: () => null),
)));
}
}
class CatalogMetadataChangedEvent {}
class TagsChangedEvent {}

View file

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/services/service_policy.dart';
import 'package:flutter/foundation.dart';
@ -12,18 +11,17 @@ import 'package:streams_channel/streams_channel.dart';
class ImageFileService {
static const platform = MethodChannel('deckers.thibault/aves/image');
static final StreamsChannel mediaStoreChannel = StreamsChannel('deckers.thibault/aves/mediastorestream');
static final StreamsChannel byteChannel = StreamsChannel('deckers.thibault/aves/imagebytestream');
static final StreamsChannel opChannel = StreamsChannel('deckers.thibault/aves/imageopstream');
static const double thumbnailDefaultSize = 64.0;
static Future<void> getImageEntries(SortFactor sort, GroupFactor group) async {
static Stream<ImageEntry> getImageEntries() {
try {
await platform.invokeMethod('getImageEntries', <String, dynamic>{
'sort': sort.toString().replaceAll('SortFactor.', ''),
'group': group.toString().replaceAll('GroupFactor.', ''),
});
return mediaStoreChannel.receiveBroadcastStream().map((event) => ImageEntry.fromMap(event));
} on PlatformException catch (e) {
debugPrint('getImageEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
return Stream.error(e);
}
}

View file

@ -1,14 +1,15 @@
import 'dart:async';
import 'package:aves/main.dart';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/album/filter_bar.dart';
import 'package:aves/widgets/album/search/search_delegate.dart';
import 'package:aves/widgets/common/action_delegates/selection_action_delegate.dart';
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
import 'package:aves/widgets/common/entry_actions.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/common/menu_row.dart';
@ -210,10 +211,15 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
..._buildGroupMenuItems(),
if (collection.isBrowsing) ...[
if (AvesApp.mode == AppMode.main)
const PopupMenuItem(
value: CollectionAction.select,
child: MenuRow(text: 'Select', icon: AIcons.select),
),
if (kDebugMode)
const PopupMenuItem(
value: CollectionAction.refresh,
child: MenuRow(text: 'Refresh', icon: AIcons.refresh),
),
const PopupMenuItem(
value: CollectionAction.select,
child: MenuRow(text: 'Select', icon: AIcons.select),
),
const PopupMenuItem(
value: CollectionAction.stats,
child: MenuRow(text: 'Stats', icon: AIcons.stats),
@ -303,6 +309,13 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
case CollectionAction.move:
_actionDelegate.onCollectionActionSelected(context, action);
break;
case CollectionAction.refresh:
final source = collection.source;
if (source is MediaStoreSource) {
source.clearEntries();
unawaited(source.refresh());
}
break;
case CollectionAction.select:
collection.select();
break;
@ -361,7 +374,21 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
}
}
enum CollectionAction { copy, move, select, selectAll, selectNone, stats, groupByAlbum, groupByMonth, groupByDay, sortByDate, sortBySize, sortByName }
enum CollectionAction {
copy,
move,
refresh,
select,
selectAll,
selectNone,
stats,
groupByAlbum,
groupByMonth,
groupByDay,
sortByDate,
sortBySize,
sortByName,
}
class SourceStateSubtitle extends StatefulWidget {
final CollectionSource source;
@ -379,7 +406,7 @@ class _SourceStateSubtitleState extends State<SourceStateSubtitle> {
SourceState get sourceState => source.stateNotifier.value;
List<ImageEntry> get entries => source.entries;
List<ImageEntry> get entries => source.rawEntries;
@override
void initState() {

View file

@ -1,4 +1,4 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/album/thumbnail_collection.dart';
import 'package:aves/widgets/app_drawer.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';

View file

@ -1,7 +1,7 @@
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/constants.dart';
import 'package:aves/utils/durations.dart';

View file

@ -1,7 +1,7 @@
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/album/grid/header_generic.dart';
import 'package:aves/widgets/album/grid/tile_extent_manager.dart';
import 'package:collection/collection.dart';

View file

@ -1,6 +1,6 @@
import 'package:aves/main.dart';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/viewer_service.dart';
import 'package:aves/widgets/album/grid/list_known_extent.dart';
import 'package:aves/widgets/album/grid/list_section_layout.dart';

View file

@ -1,4 +1,3 @@
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
@ -7,6 +6,10 @@ import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/query.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/widgets/album/search/expandable_filter_row.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/icons.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/album/thumbnail/overlay.dart';
import 'package:aves/widgets/album/thumbnail/raster.dart';
import 'package:aves/widgets/album/thumbnail/vector.dart';

View file

@ -1,7 +1,7 @@
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/fx/sweeper.dart';
import 'package:aves/widgets/common/icons.dart';

View file

@ -1,10 +1,10 @@
import 'dart:async';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/album/app_bar.dart';
import 'package:aves/widgets/album/empty.dart';

View file

@ -1,7 +1,5 @@
import 'dart:ui';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/filters.dart';
@ -10,6 +8,11 @@ import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/album.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/source/location.dart';
import 'package:aves/model/source/tag.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/album/collection_page.dart';

View file

@ -1,7 +1,7 @@
import 'dart:io';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/widgets/common/action_delegates/feedback.dart';

View file

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/image_entry.dart';

View file

@ -1,23 +1,18 @@
import 'dart:math';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/metadata_db.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_native_timezone/flutter_native_timezone.dart';
class MediaStoreSource {
final CollectionSource source = CollectionSource();
static const EventChannel _eventChannel = EventChannel('deckers.thibault/aves/mediastore');
Future<void> fetch() async {
class MediaStoreSource extends CollectionSource {
Future<void> init() async {
final stopwatch = Stopwatch()..start();
source.stateNotifier.value = SourceState.loading;
stateNotifier.value = SourceState.loading;
await metadataDb.init(); // <20ms
await favourites.init();
final currentTimeZone = await FlutterNativeTimezone.getLocalTimezone(); // <20ms
@ -29,40 +24,44 @@ class MediaStoreSource {
await metadataDb.clearMetadataEntries();
settings.catalogTimeZone = currentTimeZone;
}
await source.loadDates(); // 100ms for 5400 entries
await loadDates(); // 100ms for 5400 entries
debugPrint('$runtimeType init done, elapsed=${stopwatch.elapsed}');
}
Future<void> refresh() async {
final stopwatch = Stopwatch()..start();
stateNotifier.value = SourceState.loading;
var refreshCount = 10;
const refreshCountMax = 1000;
final allEntries = <ImageEntry>[];
_eventChannel.receiveBroadcastStream().cast<Map>().listen(
(entryMap) {
allEntries.add(ImageEntry.fromMap(entryMap));
// TODO split image fetch AND/OR cache fetch across sessions
ImageFileService.getImageEntries().listen(
(entry) {
allEntries.add(entry);
if (allEntries.length >= refreshCount) {
refreshCount = min(refreshCount * 10, refreshCountMax);
source.addAll(allEntries);
addAll(allEntries);
allEntries.clear();
// debugPrint('$runtimeType streamed ${_source.entries.length} entries at ${stopwatch.elapsed.inMilliseconds}ms');
// debugPrint('$runtimeType streamed ${entries.length} entries at ${stopwatch.elapsed.inMilliseconds}ms');
}
},
onDone: () async {
debugPrint('$runtimeType stream complete at ${stopwatch.elapsed.inMilliseconds}ms');
source.addAll(allEntries);
debugPrint('$runtimeType stream done, elapsed=${stopwatch.elapsed}');
addAll(allEntries);
// TODO reduce setup time until here
source.updateAlbums(); // <50ms
source.stateNotifier.value = SourceState.cataloguing;
await source.loadCatalogMetadata(); // 400ms for 5400 entries
await source.catalogEntries(); // <50ms
source.stateNotifier.value = SourceState.locating;
await source.loadAddresses(); // 350ms
await source.locateEntries(); // <50ms
source.stateNotifier.value = SourceState.ready;
debugPrint('$runtimeType setup end, elapsed=${stopwatch.elapsed}');
updateAlbums(); // <50ms
stateNotifier.value = SourceState.cataloguing;
await loadCatalogMetadata(); // 400ms for 5400 entries
await catalogEntries(); // <50ms
stateNotifier.value = SourceState.locating;
await loadAddresses(); // 350ms
await locateEntries(); // <50ms
stateNotifier.value = SourceState.ready;
debugPrint('$runtimeType refresh done, elapsed=${stopwatch.elapsed}');
},
onError: (error) => debugPrint('$runtimeType mediastore stream error=$error'),
onError: (error) => debugPrint('$runtimeType stream error=$error'),
);
// TODO split image fetch AND/OR cache fetch across sessions
debugPrint('$runtimeType stream start at ${stopwatch.elapsed.inMilliseconds}ms');
await ImageFileService.getImageEntries(settings.collectionSortFactor, settings.collectionGroupFactor); // 460ms
}
}

View file

@ -35,6 +35,7 @@ class AIcons {
static const IconData info = OMIcons.info;
static const IconData openInNew = OMIcons.openInNew;
static const IconData print = OMIcons.print;
static const IconData refresh = OMIcons.refresh;
static const IconData rename = OMIcons.title;
static const IconData rotateLeft = OMIcons.rotateLeft;
static const IconData rotateRight = OMIcons.rotateRight;

View file

@ -1,6 +1,6 @@
import 'dart:collection';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/image_metadata.dart';
@ -37,7 +37,7 @@ class DebugPageState extends State<DebugPage> {
Future<List<Tuple2<String, bool>>> _volumePermissionLoader;
Future<Map> _envLoader;
List<ImageEntry> get entries => widget.source.entries;
List<ImageEntry> get entries => widget.source.rawEntries;
@override
void initState() {

View file

@ -1,11 +1,11 @@
import 'dart:ui';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/collection_source.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/album/collection_page.dart';

View file

@ -1,7 +1,7 @@
import 'dart:io';
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/utils/change_notifier.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';
import 'package:aves/widgets/fullscreen/fullscreen_body.dart';
import 'package:flutter/material.dart';

View file

@ -1,5 +1,5 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/widgets/fullscreen/image_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_ijkplayer/flutter_ijkplayer.dart';

View file

@ -1,4 +1,3 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/favourite_repo.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/favourite.dart';
@ -6,6 +5,7 @@ import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/filters/tag.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/mime_types.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/file_utils.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/fullscreen/info/info_page.dart';

View file

@ -1,6 +1,6 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';
import 'package:aves/widgets/common/data_providers/media_query_data_provider.dart';

View file

@ -1,4 +1,4 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';

114
lib/widgets/home_page.dart Normal file
View file

@ -0,0 +1,114 @@
import 'package:aves/main.dart';
import 'package:aves/model/image_entry.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/services/image_file_service.dart';
import 'package:aves/services/viewer_service.dart';
import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/common/data_providers/media_store_collection_provider.dart';
import 'package:aves/widgets/common/icons.dart';
import 'package:aves/widgets/fullscreen/fullscreen_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:pedantic/pedantic.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:screen/screen.dart';
class HomePage extends StatefulWidget {
const HomePage();
@override
_HomePageState createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
MediaStoreSource _mediaStore;
ImageEntry _viewerEntry;
Future<void> _appSetup;
@override
void initState() {
super.initState();
_appSetup = _setup();
imageCache.maximumSizeBytes = 512 * (1 << 20);
Screen.keepOn(true);
}
Future<void> _setup() async {
debugPrint('$runtimeType _setup');
// TODO reduce permission check time
final permissions = await [
Permission.storage,
// to access media with unredacted metadata with scoped storage (Android 10+)
Permission.accessMediaLocation,
].request(); // 350ms
if (permissions[Permission.storage] != PermissionStatus.granted) {
unawaited(SystemNavigator.pop());
return;
}
// TODO notify when icons are ready for drawer and section header refresh
await androidFileUtils.init(); // 170ms
final intentData = await ViewerService.getIntentData();
if (intentData != null) {
final action = intentData['action'];
switch (action) {
case 'view':
AvesApp.mode = AppMode.view;
_viewerEntry = await _initViewerEntry(
uri: intentData['uri'],
mimeType: intentData['mimeType'],
);
if (_viewerEntry == null) {
// fallback to default mode when we fail to retrieve the entry
AvesApp.mode = AppMode.main;
}
break;
case 'pick':
AvesApp.mode = AppMode.pick;
break;
}
}
if (AvesApp.mode != AppMode.view) {
_mediaStore = MediaStoreSource();
await _mediaStore.init();
unawaited(_mediaStore.refresh());
}
}
Future<ImageEntry> _initViewerEntry({@required String uri, @required String mimeType}) async {
final entry = await ImageFileService.getImageEntry(uri, mimeType);
if (entry != null) {
// cataloguing is essential for geolocation and video rotation
await entry.catalog();
unawaited(entry.locate());
}
return entry;
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _appSetup,
builder: (context, AsyncSnapshot<void> snapshot) {
if (snapshot.hasError) return const Icon(AIcons.error);
if (snapshot.connectionState != ConnectionState.done) return const SizedBox.shrink();
debugPrint('$runtimeType app setup future complete');
if (AvesApp.mode == AppMode.view) {
return SingleFullscreenPage(entry: _viewerEntry);
}
if (_mediaStore != null) {
return CollectionPage(CollectionLens(
source: _mediaStore,
groupFactor: settings.collectionGroupFactor,
sortFactor: settings.collectionSortFactor,
));
}
return const SizedBox.shrink();
});
}
}

View file

@ -1,5 +1,5 @@
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/utils/color_utils.dart';
import 'package:aves/widgets/album/collection_page.dart';
import 'package:aves/widgets/common/aves_filter_chip.dart';

View file

@ -1,6 +1,6 @@
import 'dart:math';
import 'package:aves/model/collection_lens.dart';
import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/filters/filters.dart';
import 'package:aves/model/filters/location.dart';
import 'package:aves/model/filters/tag.dart';

View file

@ -1,9 +1,9 @@
import 'package:aves/main.dart';
import 'package:aves/model/settings.dart';
import 'package:aves/model/terms.dart';
import 'package:aves/utils/durations.dart';
import 'package:aves/widgets/common/aves_logo.dart';
import 'package:aves/widgets/common/labeled_checkbox.dart';
import 'package:aves/widgets/home_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_staggered_animations/flutter_staggered_animations.dart';