Switch local DB from IndexedDB to SQLite

This commit is contained in:
Ming Ming 2022-07-06 04:20:24 +08:00
parent 2085b9197e
commit 2c4ec3447b
78 changed files with 7468 additions and 2785 deletions

View file

@ -31,15 +31,6 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>

10
app/build.yaml Normal file
View file

@ -0,0 +1,10 @@
targets:
$default:
builders:
drift_dev:
options:
apply_converters_on_variables: true
generate_values_in_copy_with: true
new_sql_code_generation: true
scoped_dart_components: true
generate_connect_constructor: true

View file

@ -1,499 +0,0 @@
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:idb_shim/idb.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/upgrader.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/num_extension.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/type.dart';
class AppDb {
static const dbName = "app.db";
static const dbVersion = 6;
static const albumStoreName = "albums";
static const file2StoreName = "files2";
static const dirStoreName = "dirs";
static const metaStoreName = "meta";
/// Run [fn] with an opened database instance
///
/// This function guarantees that:
/// 1) Database is always closed after [fn] exits, even with an error
/// 2) Only at most 1 database instance being opened at any time
Future<T> use<T>(Transaction Function(Database db) transactionBuilder,
FutureOr<T> Function(Transaction transaction) fn) async {
// make sure only one client is opening the db
return await platform.Lock.synchronized(k.appDbLockId, () async {
final db = await _open();
Transaction? transaction;
try {
transaction = transactionBuilder(db);
return await fn(transaction);
} finally {
if (transaction != null) {
await transaction.completed;
}
db.close();
}
});
}
Future<void> delete() async {
_log.warning("[delete] Deleting database");
return await platform.Lock.synchronized(k.appDbLockId, () async {
final dbFactory = platform.getDbFactory();
await dbFactory.deleteDatabase(dbName);
});
}
/// Open the database
Future<Database> _open() {
if (platform_k.isWeb) {
return _openNative();
} else {
return _openSqflite();
}
}
/// Open the sqflite database
///
/// We can't simply call deleteObjectStore on upgrade failure here, as the
/// package does not remove the corresponding indexes, so when we recreate
/// the indexes later, it'll fail. What we do here, is to delete the whole
/// database instead
Future<Database> _openSqflite() async {
final dbFactory = platform.getDbFactory();
try {
int? fromVersion, toVersion;
final db = await dbFactory.open(
dbName,
version: dbVersion,
onUpgradeNeeded: (event) {
_upgrade(event);
fromVersion = event.oldVersion;
toVersion = event.newVersion;
},
);
if (fromVersion != null && toVersion != null) {
await _onPostUpgrade(db, fromVersion!, toVersion!);
}
return db;
} catch (e, stackTrace) {
_log.shout(
"[_openSqflite] Failed while upgrading database", e, stackTrace);
_log.warning("[_openSqflite] Recreating db");
await dbFactory.deleteDatabase(dbName);
return dbFactory.open(dbName,
version: dbVersion, onUpgradeNeeded: _upgrade);
}
}
/// Open the native IndexedDB database
///
/// Errors thrown in onUpgradeNeeded are not propagated properly to us on web,
/// so the sqflite approach will not work
Future<Database> _openNative() async {
final dbFactory = platform.getDbFactory();
int? fromVersion, toVersion;
final db = await dbFactory.open(
dbName,
version: dbVersion,
onUpgradeNeeded: (event) {
try {
_upgrade(event);
fromVersion = event.oldVersion;
toVersion = event.newVersion;
} catch (e, stackTrace) {
_log.shout(
"[_openNative] Failed while upgrading database", e, stackTrace);
// drop the db and rebuild a new one instead
try {
event.database.deleteObjectStore(albumStoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(file2StoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(dirStoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(metaStoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(_fileDbStoreName);
} catch (_) {}
try {
event.database.deleteObjectStore(_fileStoreName);
} catch (_) {}
_log.warning("[_openNative] Recreating db");
_upgrade(_DummyVersionChangeEvent(
0,
event.newVersion,
event.transaction,
event.target,
event.currentTarget,
event.database));
}
},
);
if (fromVersion != null && toVersion != null) {
await _onPostUpgrade(db, fromVersion!, toVersion!);
}
return db;
}
void _upgrade(VersionChangeEvent event) {
_log.info("[_upgrade] Upgrade database: ${event.oldVersion} -> $dbVersion");
final db = event.database;
// ignore: unused_local_variable
ObjectStore? albumStore, file2Store, dirStore, metaStore;
if (event.oldVersion < 2) {
// version 2 store things in a new way, just drop all
try {
db.deleteObjectStore(albumStoreName);
} catch (_) {}
albumStore = db.createObjectStore(albumStoreName);
albumStore.createIndex(
AppDbAlbumEntry.indexName, AppDbAlbumEntry.keyPath);
}
if (event.oldVersion < 3) {
// new object store in v3
// no longer relevant in v4
// recreate file store from scratch
// no longer relevant in v4
}
if (event.oldVersion < 4) {
try {
db.deleteObjectStore(_fileDbStoreName);
} catch (_) {}
try {
db.deleteObjectStore(_fileStoreName);
} catch (_) {}
file2Store = db.createObjectStore(file2StoreName);
file2Store.createIndex(AppDbFile2Entry.strippedPathIndexName,
AppDbFile2Entry.strippedPathKeyPath);
dirStore = db.createObjectStore(dirStoreName);
}
file2Store ??= event.transaction.objectStore(file2StoreName);
if (event.oldVersion < 5) {
file2Store.createIndex(AppDbFile2Entry.dateTimeEpochMsIndexName,
AppDbFile2Entry.dateTimeEpochMsKeyPath);
metaStore =
db.createObjectStore(metaStoreName, keyPath: AppDbMetaEntry.keyPath);
}
if (event.oldVersion < 6) {
file2Store.createIndex(AppDbFile2Entry.fileIsFavoriteIndexName,
AppDbFile2Entry.fileIsFavoriteKeyPath);
}
}
Future<void> _onPostUpgrade(
Database db, int fromVersion, int toVersion) async {
if (fromVersion.inRange(1, 4) && toVersion >= 5) {
final transaction = db.transaction(AppDb.metaStoreName, idbModeReadWrite);
final metaStore = transaction.objectStore(AppDb.metaStoreName);
await metaStore
.put(const AppDbMetaEntryDbCompatV5(false).toEntry().toJson());
await transaction.completed;
}
}
static const _fileDbStoreName = "filesDb";
static const _fileStoreName = "files";
static final _log = Logger("app_db.AppDb");
}
class AppDbAlbumEntry {
static const indexName = "albumStore_path_index";
static const keyPath = ["path", "index"];
static const maxDataSize = 160;
AppDbAlbumEntry(this.path, this.index, this.album);
JsonObj toJson() {
return {
"path": path,
"index": index,
"album": album.toAppDbJson(),
};
}
factory AppDbAlbumEntry.fromJson(JsonObj json, Account account) {
return AppDbAlbumEntry(
json["path"],
json["index"],
Album.fromJson(
json["album"].cast<String, dynamic>(),
upgraderFactory: DefaultAlbumUpgraderFactory(
account: account,
logFilePath: json["path"],
),
)!,
);
}
static String toPath(Account account, String filePath) =>
"${account.url}/$filePath";
static String toPathFromFile(Account account, File albumFile) =>
toPath(account, albumFile.path);
static String toPrimaryKey(Account account, File albumFile, int index) =>
"${toPathFromFile(account, albumFile)}[$index]";
final String path;
final int index;
// properties other than Album.items is undefined when index > 0
final Album album;
}
class AppDbFile2Entry with EquatableMixin {
static const strippedPathIndexName = "server_userId_strippedPath";
static const strippedPathKeyPath = ["server", "userId", "strippedPath"];
static const dateTimeEpochMsIndexName = "server_userId_dateTimeEpochMs";
static const dateTimeEpochMsKeyPath = ["server", "userId", "dateTimeEpochMs"];
static const fileIsFavoriteIndexName = "server_userId_fileIsFavorite";
static const fileIsFavoriteKeyPath = ["server", "userId", "file.isFavorite"];
AppDbFile2Entry(this.server, this.userId, this.strippedPath,
this.dateTimeEpochMs, this.file);
factory AppDbFile2Entry.fromFile(Account account, File file) =>
AppDbFile2Entry(account.url, account.username, file.strippedPathWithEmpty,
file.bestDateTime.millisecondsSinceEpoch, file);
factory AppDbFile2Entry.fromJson(JsonObj json) => AppDbFile2Entry(
json["server"],
(json["userId"] as String).toCi(),
json["strippedPath"],
json["dateTimeEpochMs"],
File.fromJson(json["file"].cast<String, dynamic>()),
);
JsonObj toJson() => {
"server": server,
"userId": userId.toCaseInsensitiveString(),
"strippedPath": strippedPath,
"dateTimeEpochMs": dateTimeEpochMs,
"file": file.toJson(),
};
static String toPrimaryKey(Account account, int fileId) =>
"${account.url}/${account.username.toCaseInsensitiveString()}/$fileId";
static String toPrimaryKeyForFile(Account account, File file) =>
toPrimaryKey(account, file.fileId!);
static List<Object> toStrippedPathIndexKey(
Account account, String strippedPath) =>
[
account.url,
account.username.toCaseInsensitiveString(),
strippedPath == "." ? "" : strippedPath
];
static List<Object> toStrippedPathIndexKeyForFile(
Account account, File file) =>
toStrippedPathIndexKey(account, file.strippedPathWithEmpty);
/// Return the lower bound key used to query files under [dir] and its sub
/// dirs
static List<Object> toStrippedPathIndexLowerKeyForDir(
Account account, File dir) =>
[
account.url,
account.username.toCaseInsensitiveString(),
dir.strippedPath.run((p) => p == "." ? "" : "$p/")
];
/// Return the upper bound key used to query files under [dir] and its sub
/// dirs
static List<Object> toStrippedPathIndexUpperKeyForDir(
Account account, File dir) {
return toStrippedPathIndexLowerKeyForDir(account, dir).run((k) {
k[2] = (k[2] as String) + "\uffff";
return k;
});
}
static List<Object> toDateTimeEpochMsIndexKey(Account account, int epochMs) =>
[
account.url,
account.username.toCaseInsensitiveString(),
epochMs,
];
static List<Object> toFileIsFavoriteIndexKey(
Account account, bool isFavorite) =>
[
account.url,
account.username.toCaseInsensitiveString(),
isFavorite ? 1 : 0,
];
@override
get props => [
server,
userId,
strippedPath,
dateTimeEpochMs,
file,
];
/// Server URL where this file belongs to
final String server;
final CiString userId;
final String strippedPath;
final int dateTimeEpochMs;
final File file;
}
class AppDbDirEntry with EquatableMixin {
AppDbDirEntry._(
this.server, this.userId, this.strippedPath, this.dir, this.children);
factory AppDbDirEntry.fromFiles(
Account account, File dir, List<File> children) =>
AppDbDirEntry._(
account.url,
account.username,
dir.strippedPathWithEmpty,
dir,
children.map((f) => f.fileId!).toList(),
);
factory AppDbDirEntry.fromJson(JsonObj json) => AppDbDirEntry._(
json["server"],
(json["userId"] as String).toCi(),
json["strippedPath"],
File.fromJson((json["dir"] as Map).cast<String, dynamic>()),
json["children"].cast<int>(),
);
JsonObj toJson() => {
"server": server,
"userId": userId.toCaseInsensitiveString(),
"strippedPath": strippedPath,
"dir": dir.toJson(),
"children": children,
};
static String toPrimaryKeyForDir(Account account, File dir) =>
"${account.url}/${account.username.toCaseInsensitiveString()}/${dir.strippedPathWithEmpty}";
/// Return the lower bound key used to query dirs under [root] and its sub
/// dirs
static String toPrimaryLowerKeyForSubDirs(Account account, File root) {
final strippedPath = root.strippedPath.run((p) => p == "." ? "" : "$p/");
return "${account.url}/${account.username.toCaseInsensitiveString()}/$strippedPath";
}
/// Return the upper bound key used to query dirs under [root] and its sub
/// dirs
static String toPrimaryUpperKeyForSubDirs(Account account, File root) =>
toPrimaryLowerKeyForSubDirs(account, root) + "\uffff";
@override
get props => [
server,
userId,
strippedPath,
dir,
children,
];
/// Server URL where this file belongs to
final String server;
final CiString userId;
final String strippedPath;
final File dir;
final List<int> children;
}
class AppDbMetaEntry with EquatableMixin {
static const keyPath = "key";
const AppDbMetaEntry(this.key, this.obj);
factory AppDbMetaEntry.fromJson(JsonObj json) => AppDbMetaEntry(
json["key"],
json["obj"].cast<String, dynamic>(),
);
JsonObj toJson() => {
"key": key,
"obj": obj,
};
@override
get props => [
key,
obj,
];
final String key;
final JsonObj obj;
}
class AppDbMetaEntryDbCompatV5 {
static const key = "dbCompatV5";
const AppDbMetaEntryDbCompatV5(this.isMigrated);
factory AppDbMetaEntryDbCompatV5.fromJson(JsonObj json) =>
AppDbMetaEntryDbCompatV5(json["isMigrated"]);
AppDbMetaEntry toEntry() => AppDbMetaEntry(key, {
"isMigrated": isMigrated,
});
final bool isMigrated;
}
class AppDbMetaEntryCompatV37 {
static const key = "compatV37";
const AppDbMetaEntryCompatV37(this.isMigrated);
factory AppDbMetaEntryCompatV37.fromJson(JsonObj json) =>
AppDbMetaEntryCompatV37(json["isMigrated"]);
AppDbMetaEntry toEntry() => AppDbMetaEntry(key, {
"isMigrated": isMigrated,
});
final bool isMigrated;
}
class _DummyVersionChangeEvent implements VersionChangeEvent {
const _DummyVersionChangeEvent(this.oldVersion, this.newVersion,
this.transaction, this.target, this.currentTarget, this.database);
@override
final int oldVersion;
@override
final int newVersion;
@override
final Transaction transaction;
@override
final Object target;
@override
final Object currentTarget;
@override
final Database database;
}

View file

@ -1,12 +1,13 @@
import 'package:drift/drift.dart';
import 'package:equatable/equatable.dart';
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/data_source.dart';
import 'package:nc_photos/entity/face.dart';
import 'package:nc_photos/entity/face/data_source.dart';
import 'package:nc_photos/entity/favorite.dart';
@ -21,6 +22,8 @@ import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/share/data_source.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/entity/sharee/data_source.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_isolate.dart' as sql_isolate;
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/entity/tag/data_source.dart';
import 'package:nc_photos/entity/tagged_file.dart';
@ -34,13 +37,19 @@ import 'package:nc_photos/pref.dart';
import 'package:nc_photos/pref_util.dart' as pref_util;
import 'package:visibility_detector/visibility_detector.dart';
Future<void> initAppLaunch() async {
enum InitIsolateType {
main,
service,
}
Future<void> init(InitIsolateType isolateType) async {
if (_hasInitedInThisIsolate) {
_log.warning("[initAppLaunch] Already initialized in this isolate");
return;
}
initLog();
_initDrift();
_initKiwi();
await _initPref();
await _initAccountPrefs();
@ -49,7 +58,7 @@ Future<void> initAppLaunch() async {
if (features.isSupportSelfSignedCert) {
_initSelfSignedCertManager();
}
_initDiContainer();
await _initDiContainer(isolateType);
_initVisibilityDetector();
_hasInitedInThisIsolate = true;
@ -99,6 +108,10 @@ void initLog() {
});
}
void _initDrift() {
driftRuntimeOptions.debugPrint = _debugPrintSql;
}
Future<void> _initPref() async {
final provider = PrefSharedPreferencesProvider();
await provider.init();
@ -146,31 +159,56 @@ void _initSelfSignedCertManager() {
SelfSignedCertManager().init();
}
void _initDiContainer() {
LocalFileRepo? localFileRepo;
Future<void> _initDiContainer(InitIsolateType isolateType) async {
final c = DiContainer.late();
c.pref = Pref();
c.sqliteDb = await _createDb(isolateType);
c.albumRepo = AlbumRepo(AlbumCachedDataSource(c));
c.albumRepoLocal = AlbumRepo(AlbumSqliteDbDataSource(c));
c.faceRepo = const FaceRepo(FaceRemoteDataSource());
c.fileRepo = FileRepo(FileCachedDataSource(c));
c.fileRepoRemote = const FileRepo(FileWebdavDataSource());
c.fileRepoLocal = FileRepo(FileSqliteDbDataSource(c));
c.personRepo = const PersonRepo(PersonRemoteDataSource());
c.shareRepo = ShareRepo(ShareRemoteDataSource());
c.shareeRepo = ShareeRepo(ShareeRemoteDataSource());
c.favoriteRepo = const FavoriteRepo(FavoriteRemoteDataSource());
c.tagRepo = const TagRepo(TagRemoteDataSource());
c.taggedFileRepo = const TaggedFileRepo(TaggedFileRemoteDataSource());
if (platform_k.isAndroid) {
// local file currently only supported on Android
localFileRepo = const LocalFileRepo(LocalFileMediaStoreDataSource());
c.localFileRepo = const LocalFileRepo(LocalFileMediaStoreDataSource());
}
KiwiContainer().registerInstance<DiContainer>(DiContainer(
albumRepo: AlbumRepo(AlbumCachedDataSource(AppDb())),
faceRepo: const FaceRepo(FaceRemoteDataSource()),
fileRepo: FileRepo(FileCachedDataSource(AppDb())),
personRepo: const PersonRepo(PersonRemoteDataSource()),
shareRepo: ShareRepo(ShareRemoteDataSource()),
shareeRepo: ShareeRepo(ShareeRemoteDataSource()),
favoriteRepo: const FavoriteRepo(FavoriteRemoteDataSource()),
tagRepo: const TagRepo(TagRemoteDataSource()),
taggedFileRepo: const TaggedFileRepo(TaggedFileRemoteDataSource()),
localFileRepo: localFileRepo,
appDb: AppDb(),
pref: Pref(),
));
KiwiContainer().registerInstance<DiContainer>(c);
}
void _initVisibilityDetector() {
VisibilityDetectorController.instance.updateInterval = Duration.zero;
}
Future<sql.SqliteDb> _createDb(InitIsolateType isolateType) async {
switch (isolateType) {
case InitIsolateType.main:
// use driftIsolate to prevent DB blocking the UI thread
if (platform_k.isWeb) {
// no isolate support on web
return sql.SqliteDb();
} else {
return sql_isolate.createDb();
}
case InitIsolateType.service:
// service already runs in an isolate
return sql.SqliteDb();
}
}
void _debugPrintSql(String log) {
_log.finer(log);
}
final _log = Logger("app_init");
var _hasInitedInThisIsolate = false;

View file

@ -5,8 +5,6 @@ import 'package:nc_photos/account.dart';
import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util;
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/event/event.dart';
@ -153,8 +151,8 @@ class ListAlbumBloc extends Bloc<ListAlbumBlocEvent, ListAlbumBlocState> {
_log.info("[of] New bloc instance for account: $account");
final c = KiwiContainer().resolve<DiContainer>();
final offlineC = c.copyWith(
fileRepo: OrNull(FileRepo(FileAppDbDataSource(c.appDb))),
albumRepo: OrNull(AlbumRepo(AlbumAppDbDataSource(c.appDb))),
fileRepo: OrNull(c.fileRepoLocal),
albumRepo: OrNull(c.albumRepoLocal),
);
final bloc = ListAlbumBloc(c, offlineC);
KiwiContainer().registerInstance<ListAlbumBloc>(bloc, name: name);

View file

@ -3,8 +3,8 @@ import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util;
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/throttler.dart';
@ -175,9 +175,9 @@ class LsTrashbinBloc extends Bloc<LsTrashbinBlocEvent, LsTrashbinBlocState> {
}
Future<List<File>> _query(LsTrashbinBlocQuery ev) async {
final c = KiwiContainer().resolve<DiContainer>();
// caching contents in trashbin doesn't sounds useful
const fileRepo = FileRepo(FileWebdavDataSource());
final files = await LsTrashbin(fileRepo)(ev.account);
final files = await LsTrashbin(c.fileRepoRemote)(ev.account);
return files.where((f) => file_util.isSupportedFormat(f)).toList();
}

View file

@ -5,7 +5,6 @@ import 'package:collection/collection.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/bloc/bloc_util.dart' as bloc_util;
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
@ -108,6 +107,11 @@ class ScanAccountDirBlocInconsistent extends ScanAccountDirBlocState {
class ScanAccountDirBloc
extends Bloc<ScanAccountDirBlocEvent, ScanAccountDirBlocState> {
ScanAccountDirBloc._(this.account) : super(const ScanAccountDirBlocInit()) {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
assert(ScanDirOffline.require(c));
_c = c;
_fileRemovedEventListener.begin();
_filePropertyUpdatedEventListener.begin();
_fileTrashbinRestoredEventListener.begin();
@ -132,6 +136,8 @@ class ScanAccountDirBloc
}));
}
static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo);
static ScanAccountDirBloc of(Account account) {
final name =
bloc_util.getInstNameForRootAwareAccount("ScanAccountDirBloc", account);
@ -317,12 +323,11 @@ class ScanAccountDirBloc
}
Future<List<File>> _queryOffline(ScanAccountDirBlocQueryBase ev) async {
final c = KiwiContainer().resolve<DiContainer>();
final files = <File>[];
for (final r in account.roots) {
try {
final dir = File(path: file_util.unstripPath(account, r));
files.addAll(await ScanDirOffline(c)(account, dir,
files.addAll(await ScanDirOffline(_c)(account, dir,
isOnlySupportedFormat: false));
} catch (e, stackTrace) {
_log.shout(
@ -341,11 +346,12 @@ class ScanAccountDirBloc
final cacheMap = FileForwardCacheManager.prepareFileMap(cache);
{
final stopwatch = Stopwatch()..start();
final fileRepo = FileRepo(FileCachedDataSource(AppDb(),
forwardCacheManager: FileForwardCacheManager(AppDb(), cacheMap)));
final fileRepoNoCache = FileRepo(FileCachedDataSource(AppDb()));
final fileRepo = FileRepo(FileCachedDataSource(
_c,
forwardCacheManager: FileForwardCacheManager(_c, cacheMap),
));
await for (final event in _queryWithFileRepo(fileRepo, ev,
fileRepoForShareDir: fileRepoNoCache)) {
fileRepoForShareDir: _c.fileRepo)) {
if (event is ExceptionEvent) {
_log.shout("[_queryOnline] Exception while request (1st pass)",
event.error, event.stackTrace);
@ -378,7 +384,8 @@ class ScanAccountDirBloc
emit(ScanAccountDirBlocLoading(files));
}
files = await _queryOnlinePass2(ev, cacheMap, files);
// files = await _queryOnlinePass2(ev, cacheMap, files);
files = await _queryOnlinePass2(ev, {}, files);
}
} catch (e, stackTrace) {
_log.shout(
@ -394,9 +401,11 @@ class ScanAccountDirBloc
// files
final pass2CacheMap = CombinedMapView(
[FileForwardCacheManager.prepareFileMap(pass1Files), cacheMap]);
final fileRepo = FileRepo(FileCachedDataSource(AppDb(),
shouldCheckCache: true,
forwardCacheManager: FileForwardCacheManager(AppDb(), pass2CacheMap)));
final fileRepo = FileRepo(FileCachedDataSource(
_c,
shouldCheckCache: true,
forwardCacheManager: FileForwardCacheManager(_c, pass2CacheMap),
));
final remoteTouchEtag =
await touchTokenManager.getRemoteRootEtag(fileRepo, account);
if (remoteTouchEtag == null) {
@ -412,7 +421,7 @@ class ScanAccountDirBloc
final stopwatch = Stopwatch()..start();
final fileRepoNoCache =
FileRepo(FileCachedDataSource(AppDb(), shouldCheckCache: true));
FileRepo(FileCachedDataSource(_c, shouldCheckCache: true));
final newFiles = <File>[];
await for (final event in _queryWithFileRepo(fileRepo, ev,
fileRepoForShareDir: fileRepoNoCache)) {
@ -489,6 +498,8 @@ class ScanAccountDirBloc
return false;
}
late final DiContainer _c;
final Account account;
late final _fileRemovedEventListener =

View file

@ -1,4 +1,3 @@
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/face.dart';
import 'package:nc_photos/entity/favorite.dart';
@ -7,6 +6,7 @@ import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/entity/person.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/tag.dart';
import 'package:nc_photos/entity/tagged_file.dart';
import 'package:nc_photos/or_null.dart';
@ -14,8 +14,11 @@ import 'package:nc_photos/pref.dart';
enum DiType {
albumRepo,
albumRepoLocal,
faceRepo,
fileRepo,
fileRepoRemote,
fileRepoLocal,
personRepo,
shareRepo,
shareeRepo,
@ -23,15 +26,18 @@ enum DiType {
tagRepo,
taggedFileRepo,
localFileRepo,
appDb,
pref,
sqliteDb,
}
class DiContainer {
const DiContainer({
DiContainer({
AlbumRepo? albumRepo,
AlbumRepo? albumRepoLocal,
FaceRepo? faceRepo,
FileRepo? fileRepo,
FileRepo? fileRepoRemote,
FileRepo? fileRepoLocal,
PersonRepo? personRepo,
ShareRepo? shareRepo,
ShareeRepo? shareeRepo,
@ -39,11 +45,14 @@ class DiContainer {
TagRepo? tagRepo,
TaggedFileRepo? taggedFileRepo,
LocalFileRepo? localFileRepo,
AppDb? appDb,
Pref? pref,
sql.SqliteDb? sqliteDb,
}) : _albumRepo = albumRepo,
_albumRepoLocal = albumRepoLocal,
_faceRepo = faceRepo,
_fileRepo = fileRepo,
_fileRepoRemote = fileRepoRemote,
_fileRepoLocal = fileRepoLocal,
_personRepo = personRepo,
_shareRepo = shareRepo,
_shareeRepo = shareeRepo,
@ -51,17 +60,25 @@ class DiContainer {
_tagRepo = tagRepo,
_taggedFileRepo = taggedFileRepo,
_localFileRepo = localFileRepo,
_appDb = appDb,
_pref = pref;
_pref = pref,
_sqliteDb = sqliteDb;
DiContainer.late();
static bool has(DiContainer contianer, DiType type) {
switch (type) {
case DiType.albumRepo:
return contianer._albumRepo != null;
case DiType.albumRepoLocal:
return contianer._albumRepoLocal != null;
case DiType.faceRepo:
return contianer._faceRepo != null;
case DiType.fileRepo:
return contianer._fileRepo != null;
case DiType.fileRepoRemote:
return contianer._fileRepoRemote != null;
case DiType.fileRepoLocal:
return contianer._fileRepoLocal != null;
case DiType.personRepo:
return contianer._personRepo != null;
case DiType.shareRepo:
@ -76,17 +93,20 @@ class DiContainer {
return contianer._taggedFileRepo != null;
case DiType.localFileRepo:
return contianer._localFileRepo != null;
case DiType.appDb:
return contianer._appDb != null;
case DiType.pref:
return contianer._pref != null;
case DiType.sqliteDb:
return contianer._sqliteDb != null;
}
}
DiContainer copyWith({
OrNull<AlbumRepo>? albumRepo,
OrNull<AlbumRepo>? albumRepoLocal,
OrNull<FaceRepo>? faceRepo,
OrNull<FileRepo>? fileRepo,
OrNull<FileRepo>? fileRepoRemote,
OrNull<FileRepo>? fileRepoLocal,
OrNull<PersonRepo>? personRepo,
OrNull<ShareRepo>? shareRepo,
OrNull<ShareeRepo>? shareeRepo,
@ -94,13 +114,18 @@ class DiContainer {
OrNull<TagRepo>? tagRepo,
OrNull<TaggedFileRepo>? taggedFileRepo,
OrNull<LocalFileRepo>? localFileRepo,
OrNull<AppDb>? appDb,
OrNull<Pref>? pref,
OrNull<sql.SqliteDb>? sqliteDb,
}) {
return DiContainer(
albumRepo: albumRepo == null ? _albumRepo : albumRepo.obj,
albumRepoLocal:
albumRepoLocal == null ? _albumRepoLocal : albumRepoLocal.obj,
faceRepo: faceRepo == null ? _faceRepo : faceRepo.obj,
fileRepo: fileRepo == null ? _fileRepo : fileRepo.obj,
fileRepoRemote:
fileRepoRemote == null ? _fileRepoRemote : fileRepoRemote.obj,
fileRepoLocal: fileRepoLocal == null ? _fileRepoLocal : fileRepoLocal.obj,
personRepo: personRepo == null ? _personRepo : personRepo.obj,
shareRepo: shareRepo == null ? _shareRepo : shareRepo.obj,
shareeRepo: shareeRepo == null ? _shareeRepo : shareeRepo.obj,
@ -109,14 +134,17 @@ class DiContainer {
taggedFileRepo:
taggedFileRepo == null ? _taggedFileRepo : taggedFileRepo.obj,
localFileRepo: localFileRepo == null ? _localFileRepo : localFileRepo.obj,
appDb: appDb == null ? _appDb : appDb.obj,
pref: pref == null ? _pref : pref.obj,
sqliteDb: sqliteDb == null ? _sqliteDb : sqliteDb.obj,
);
}
AlbumRepo get albumRepo => _albumRepo!;
AlbumRepo get albumRepoLocal => _albumRepoLocal!;
FaceRepo get faceRepo => _faceRepo!;
FileRepo get fileRepo => _fileRepo!;
FileRepo get fileRepoRemote => _fileRepoRemote!;
FileRepo get fileRepoLocal => _fileRepoLocal!;
PersonRepo get personRepo => _personRepo!;
ShareRepo get shareRepo => _shareRepo!;
ShareeRepo get shareeRepo => _shareeRepo!;
@ -125,20 +153,101 @@ class DiContainer {
TaggedFileRepo get taggedFileRepo => _taggedFileRepo!;
LocalFileRepo get localFileRepo => _localFileRepo!;
AppDb get appDb => _appDb!;
sql.SqliteDb get sqliteDb => _sqliteDb!;
Pref get pref => _pref!;
final AlbumRepo? _albumRepo;
final FaceRepo? _faceRepo;
final FileRepo? _fileRepo;
final PersonRepo? _personRepo;
final ShareRepo? _shareRepo;
final ShareeRepo? _shareeRepo;
final FavoriteRepo? _favoriteRepo;
final TagRepo? _tagRepo;
final TaggedFileRepo? _taggedFileRepo;
final LocalFileRepo? _localFileRepo;
set albumRepo(AlbumRepo v) {
assert(_albumRepo == null);
_albumRepo = v;
}
final AppDb? _appDb;
final Pref? _pref;
set albumRepoLocal(AlbumRepo v) {
assert(_albumRepoLocal == null);
_albumRepoLocal = v;
}
set faceRepo(FaceRepo v) {
assert(_faceRepo == null);
_faceRepo = v;
}
set fileRepo(FileRepo v) {
assert(_fileRepo == null);
_fileRepo = v;
}
set fileRepoRemote(FileRepo v) {
assert(_fileRepoRemote == null);
_fileRepoRemote = v;
}
set fileRepoLocal(FileRepo v) {
assert(_fileRepoLocal == null);
_fileRepoLocal = v;
}
set personRepo(PersonRepo v) {
assert(_personRepo == null);
_personRepo = v;
}
set shareRepo(ShareRepo v) {
assert(_shareRepo == null);
_shareRepo = v;
}
set shareeRepo(ShareeRepo v) {
assert(_shareeRepo == null);
_shareeRepo = v;
}
set favoriteRepo(FavoriteRepo v) {
assert(_favoriteRepo == null);
_favoriteRepo = v;
}
set tagRepo(TagRepo v) {
assert(_tagRepo == null);
_tagRepo = v;
}
set taggedFileRepo(TaggedFileRepo v) {
assert(_taggedFileRepo == null);
_taggedFileRepo = v;
}
set localFileRepo(LocalFileRepo v) {
assert(_localFileRepo == null);
_localFileRepo = v;
}
set sqliteDb(sql.SqliteDb v) {
assert(_sqliteDb == null);
_sqliteDb = v;
}
set pref(Pref v) {
assert(_pref == null);
_pref = v;
}
AlbumRepo? _albumRepo;
// Explicitly request a AlbumRepo backed by local source
AlbumRepo? _albumRepoLocal;
FaceRepo? _faceRepo;
FileRepo? _fileRepo;
// Explicitly request a FileRepo backed by remote source
FileRepo? _fileRepoRemote;
// Explicitly request a FileRepo backed by local source
FileRepo? _fileRepoLocal;
PersonRepo? _personRepo;
ShareRepo? _shareRepo;
ShareeRepo? _shareeRepo;
FavoriteRepo? _favoriteRepo;
TagRepo? _tagRepo;
TaggedFileRepo? _taggedFileRepo;
LocalFileRepo? _localFileRepo;
sql.SqliteDb? _sqliteDb;
Pref? _pref;
}

View file

@ -1,33 +1,16 @@
import 'dart:convert';
import 'dart:math';
import 'package:equatable/equatable.dart';
import 'package:idb_sqflite/idb_sqflite.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album/cover_provider.dart';
import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/entity/album/upgrader.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/int_util.dart' as int_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
import 'package:nc_photos/type.dart';
import 'package:nc_photos/use_case/get_file_binary.dart';
import 'package:nc_photos/use_case/ls_single_file.dart';
import 'package:nc_photos/use_case/put_file_binary.dart';
import 'package:quiver/iterables.dart';
import 'package:tuple/tuple.dart';
/// Immutable object that represents an album
class Album with EquatableMixin {
@ -229,6 +212,8 @@ class Album with EquatableMixin {
/// versioning of this class, use to upgrade old persisted album
static const version = 8;
static final _log = Logger("entity.album.Album");
}
class AlbumShare with EquatableMixin {
@ -299,6 +284,10 @@ class AlbumRepo {
Future<Album> get(Account account, File albumFile) =>
dataSrc.get(account, albumFile);
/// See [AlbumDataSource.getAll]
Stream<dynamic> getAll(Account account, List<File> albumFiles) =>
dataSrc.getAll(account, albumFiles);
/// See [AlbumDataSource.create]
Future<Album> create(Account account, Album album) =>
dataSrc.create(account, album);
@ -307,11 +296,6 @@ class AlbumRepo {
Future<void> update(Account account, Album album) =>
dataSrc.update(account, album);
/// See [AlbumDataSource.cleanUp]
Future<void> cleanUp(
Account account, String rootDir, List<File> albumFiles) =>
dataSrc.cleanUp(account, rootDir, albumFiles);
final AlbumDataSource dataSrc;
}
@ -319,292 +303,12 @@ abstract class AlbumDataSource {
/// Return the album defined by [albumFile]
Future<Album> get(Account account, File albumFile);
/// Emit albums defined by [albumFiles] or ExceptionEvent
Stream<dynamic> getAll(Account account, List<File> albumFiles);
// Create a new album
Future<Album> create(Account account, Album album);
/// Update an album
Future<void> update(Account account, Album album);
/// Clean up cached albums
///
/// Remove dangling albums in cache not listed in [albumFiles] and located
/// inside [rootDir]. Do nothing if this data source does not cache previous
/// results
Future<void> cleanUp(Account account, String rootDir, List<File> albumFiles);
}
class AlbumRemoteDataSource implements AlbumDataSource {
@override
get(Account account, File albumFile) async {
_log.info("[get] ${albumFile.path}");
const fileRepo = FileRepo(FileWebdavDataSource());
final data = await GetFileBinary(fileRepo)(account, albumFile);
try {
return Album.fromJson(
jsonDecode(utf8.decode(data)),
upgraderFactory: DefaultAlbumUpgraderFactory(
account: account,
albumFile: albumFile,
logFilePath: albumFile.path,
),
)!
.copyWith(
lastUpdated: OrNull(null),
albumFile: OrNull(albumFile),
);
} catch (e, stacktrace) {
dynamic d = data;
try {
d = utf8.decode(data);
} catch (_) {}
_log.severe("[get] Invalid json data: $d", e, stacktrace);
throw const FormatException("Invalid album format");
}
}
@override
create(Account account, Album album) async {
_log.info("[create]");
final fileName = _makeAlbumFileName();
final filePath =
"${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName";
const fileRepo = FileRepo(FileWebdavDataSource());
await PutFileBinary(fileRepo)(account, filePath,
const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())),
shouldCreateMissingDir: true);
// query album file
final newFile = await LsSingleFile(KiwiContainer().resolve<DiContainer>())(
account, filePath);
return album.copyWith(albumFile: OrNull(newFile));
}
@override
update(Account account, Album album) async {
_log.info("[update] ${album.albumFile!.path}");
const fileRepo = FileRepo(FileWebdavDataSource());
await PutFileBinary(fileRepo)(account, album.albumFile!.path,
const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())));
}
@override
cleanUp(Account account, String rootDir, List<File> albumFiles) async {}
String _makeAlbumFileName() {
// just make up something
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = Random().nextInt(0xFFFFFF);
return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.nc_album.json";
}
static final _log = Logger("entity.album.AlbumRemoteDataSource");
}
class AlbumAppDbDataSource implements AlbumDataSource {
const AlbumAppDbDataSource(this.appDb);
@override
get(Account account, File albumFile) {
_log.info("[get] ${albumFile.path}");
return appDb.use(
(db) => db.transaction(AppDb.albumStoreName, idbModeReadOnly),
(transaction) async {
final store = transaction.objectStore(AppDb.albumStoreName);
final index = store.index(AppDbAlbumEntry.indexName);
final path = AppDbAlbumEntry.toPathFromFile(account, albumFile);
final range = KeyRange.bound([path, 0], [path, int_util.int32Max]);
final List results = await index.getAll(range);
if (results.isNotEmpty == true) {
final entries = results.map((e) =>
AppDbAlbumEntry.fromJson(e.cast<String, dynamic>(), account));
if (entries.length > 1) {
final items = entries.map((e) {
_log.info("[get] ${e.path}[${e.index}]");
return AlbumStaticProvider.of(e.album).items;
}).reduce((value, element) => value + element);
return entries.first.album.copyWith(
lastUpdated: OrNull(null),
provider: AlbumStaticProvider.of(entries.first.album).copyWith(
items: items,
),
);
} else {
return entries.first.album;
}
} else {
throw CacheNotFoundException("No entry: $path");
}
},
);
}
@override
create(Account account, Album album) async {
_log.info("[create]");
throw UnimplementedError();
}
@override
update(Account account, Album album) {
_log.info("[update] ${album.albumFile!.path}");
return appDb.use(
(db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite),
(transaction) async {
final store = transaction.objectStore(AppDb.albumStoreName);
await _cacheAlbum(store, account, album);
},
);
}
@override
cleanUp(Account account, String rootDir, List<File> albumFiles) async {}
final AppDb appDb;
static final _log = Logger("entity.album.AlbumAppDbDataSource");
}
class AlbumCachedDataSource implements AlbumDataSource {
AlbumCachedDataSource(this.appDb) : _appDbSrc = AlbumAppDbDataSource(appDb);
@override
get(Account account, File albumFile) async {
try {
final cache = await _appDbSrc.get(account, albumFile);
if (cache.albumFile!.etag?.isNotEmpty == true &&
cache.albumFile!.etag == albumFile.etag) {
// cache is good
_log.fine(
"[get] etag matched for ${AppDbAlbumEntry.toPathFromFile(account, albumFile)}");
return cache;
}
_log.info(
"[get] Remote content updated for ${AppDbAlbumEntry.toPathFromFile(account, albumFile)}");
} on CacheNotFoundException catch (_) {
// normal when there's no cache
} catch (e, stacktrace) {
_log.shout("[get] Cache failure", e, stacktrace);
}
// no cache
final remote = await _remoteSrc.get(account, albumFile);
await _cacheResult(account, remote);
return remote;
}
@override
update(Account account, Album album) async {
await _remoteSrc.update(account, album);
await _appDbSrc.update(account, album);
}
@override
create(Account account, Album album) => _remoteSrc.create(account, album);
@override
cleanUp(Account account, String rootDir, List<File> albumFiles) async {
appDb.use(
(db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite),
(transaction) async {
final store = transaction.objectStore(AppDb.albumStoreName);
final index = store.index(AppDbAlbumEntry.indexName);
final rootPath = AppDbAlbumEntry.toPath(account, rootDir);
final range = KeyRange.bound(
["$rootPath/", 0], ["$rootPath/\uffff", int_util.int32Max]);
final danglingKeys = await index
// get all albums for this account
.openKeyCursor(range: range, autoAdvance: true)
.map((cursor) => Tuple2((cursor.key as List)[0], cursor.primaryKey))
// and pick the dangling ones
.where((pair) => !albumFiles.any((f) =>
pair.item1 == AppDbAlbumEntry.toPathFromFile(account, f)))
// map to primary keys
.map((pair) => pair.item2)
.toList();
for (final k in danglingKeys) {
_log.fine("[cleanUp] Removing albumStore entry: $k");
try {
await store.delete(k);
} catch (e, stackTrace) {
_log.shout(
"[cleanUp] Failed removing albumStore entry", e, stackTrace);
}
}
},
);
}
Future<void> _cacheResult(Account account, Album result) {
return appDb.use(
(db) => db.transaction(AppDb.albumStoreName, idbModeReadWrite),
(transaction) async {
final store = transaction.objectStore(AppDb.albumStoreName);
await _cacheAlbum(store, account, result);
},
);
}
final AppDb appDb;
final _remoteSrc = AlbumRemoteDataSource();
final AlbumAppDbDataSource _appDbSrc;
static final _log = Logger("entity.album.AlbumCachedDataSource");
}
Future<void> _cacheAlbum(
ObjectStore store, Account account, Album album) async {
final index = store.index(AppDbAlbumEntry.indexName);
final path = AppDbAlbumEntry.toPathFromFile(account, album.albumFile!);
final range = KeyRange.bound([path, 0], [path, int_util.int32Max]);
// count number of entries for this album
final count = await index.count(range);
// cut large album into smaller pieces, needed to workaround Android DB
// limitation
final entries = <AppDbAlbumEntry>[];
if (album.provider is AlbumStaticProvider) {
var albumItemLists = partition(
AlbumStaticProvider.of(album).items, AppDbAlbumEntry.maxDataSize)
.toList();
if (albumItemLists.isEmpty) {
albumItemLists = [<AlbumItem>[]];
}
entries.addAll(albumItemLists.withIndex().map((pair) => AppDbAlbumEntry(
path,
pair.item1,
album.copyWith(
lastUpdated: OrNull(null),
provider: AlbumStaticProvider.of(album).copyWith(
items: pair.item2,
),
))));
} else {
entries.add(AppDbAlbumEntry(path, 0, album));
}
for (final e in entries) {
_log.info("[_cacheAlbum] Caching ${e.path}[${e.index}]");
await store.put(e.toJson(),
AppDbAlbumEntry.toPrimaryKey(account, e.album.albumFile!, e.index));
}
if (count > entries.length) {
// index is 0-based
final rmRange =
KeyRange.bound([path, entries.length], [path, int_util.int32Max]);
final rmKeys = await index
.openKeyCursor(range: rmRange, autoAdvance: true)
.map((cursor) => cursor.primaryKey)
.toList();
for (final k in rmKeys) {
_log.fine("[_cacheAlbum] Removing albumStore entry: $k");
try {
await store.delete(k);
} catch (e, stackTrace) {
_log.shout(
"[_cacheAlbum] Failed removing albumStore entry", e, stackTrace);
}
}
}
}
final _log = Logger("entity.album");

View file

@ -0,0 +1,307 @@
import 'dart:convert';
import 'dart:math';
import 'package:drift/drift.dart' as sql;
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/upgrader.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_converter.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/future_util.dart' as future_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
import 'package:nc_photos/use_case/get_file_binary.dart';
import 'package:nc_photos/use_case/ls_single_file.dart';
import 'package:nc_photos/use_case/put_file_binary.dart';
class AlbumRemoteDataSource implements AlbumDataSource {
@override
get(Account account, File albumFile) async {
_log.info("[get] ${albumFile.path}");
const fileRepo = FileRepo(FileWebdavDataSource());
final data = await GetFileBinary(fileRepo)(account, albumFile);
try {
return Album.fromJson(
jsonDecode(utf8.decode(data)),
upgraderFactory: DefaultAlbumUpgraderFactory(
account: account,
albumFile: albumFile,
logFilePath: albumFile.path,
),
)!
.copyWith(
lastUpdated: OrNull(null),
albumFile: OrNull(albumFile),
);
} catch (e, stacktrace) {
dynamic d = data;
try {
d = utf8.decode(data);
} catch (_) {}
_log.severe("[get] Invalid json data: $d", e, stacktrace);
throw const FormatException("Invalid album format");
}
}
@override
getAll(Account account, List<File> albumFiles) async* {
_log.info(
"[getAll] ${albumFiles.map((f) => f.filename).toReadableString()}");
final results = await future_util.waitOr(
albumFiles.map((f) => get(account, f)),
(error, stackTrace) => ExceptionEvent(error, stackTrace),
);
for (final r in results) {
yield r;
}
}
@override
create(Account account, Album album) async {
_log.info("[create]");
final fileName = _makeAlbumFileName();
final filePath =
"${remote_storage_util.getRemoteAlbumsDir(account)}/$fileName";
const fileRepo = FileRepo(FileWebdavDataSource());
await PutFileBinary(fileRepo)(account, filePath,
const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())),
shouldCreateMissingDir: true);
// query album file
final newFile = await LsSingleFile(KiwiContainer().resolve<DiContainer>())(
account, filePath);
return album.copyWith(albumFile: OrNull(newFile));
}
@override
update(Account account, Album album) async {
_log.info("[update] ${album.albumFile!.path}");
const fileRepo = FileRepo(FileWebdavDataSource());
await PutFileBinary(fileRepo)(account, album.albumFile!.path,
const Utf8Encoder().convert(jsonEncode(album.toRemoteJson())));
}
String _makeAlbumFileName() {
// just make up something
final timestamp = DateTime.now().millisecondsSinceEpoch;
final random = Random().nextInt(0xFFFFFF);
return "${timestamp.toRadixString(16)}-${random.toRadixString(16).padLeft(6, '0')}.nc_album.json";
}
static final _log = Logger("entity.album.AlbumRemoteDataSource");
}
class AlbumSqliteDbDataSource implements AlbumDataSource {
AlbumSqliteDbDataSource(this._c);
@override
get(Account account, File albumFile) async {
final results = await getAll(account, [albumFile]).toList();
if (results.first is! Album) {
throw results.first;
} else {
return results.first;
}
}
@override
getAll(Account account, List<File> albumFiles) async* {
_log.info(
"[getAll] ${albumFiles.map((f) => f.filename).toReadableString()}");
late final List<sql.CompleteFile> dbFiles;
late final List<sql.AlbumWithShare> albumWithShares;
await _c.sqliteDb.use((db) async {
dbFiles = await db.completeFilesByFileIds(
albumFiles.map((f) => f.fileId!),
appAccount: account,
);
final query = db.select(db.albums).join([
sql.leftOuterJoin(
db.albumShares, db.albumShares.album.equalsExp(db.albums.rowId)),
])
..where(db.albums.file.isIn(dbFiles.map((f) => f.file.rowId)));
albumWithShares = await query
.map((r) => sql.AlbumWithShare(
r.readTable(db.albums), r.readTableOrNull(db.albumShares)))
.get();
});
// group entries together
final fileRowIdMap = <int, sql.CompleteFile>{};
for (var f in dbFiles) {
fileRowIdMap[f.file.rowId] = f;
}
final fileIdMap = <int, Map>{};
for (final s in albumWithShares) {
final f = fileRowIdMap[s.album.file];
if (f == null) {
_log.severe("[getAll] File missing for album (rowId: ${s.album.rowId}");
} else {
if (!fileIdMap.containsKey(f.file.fileId)) {
fileIdMap[f.file.fileId] = {
"file": f,
"album": s.album,
};
}
if (s.share != null) {
(fileIdMap[f.file.fileId]!["shares"] ??= <sql.AlbumShare>[])
.add(s.share!);
}
}
}
// sort as the input list
for (final item in albumFiles.map((f) => fileIdMap[f.fileId])) {
if (item == null) {
// cache not found
yield CacheNotFoundException();
} else {
try {
final f = SqliteFileConverter.fromSql(
account.homeDir.toString(), item["file"]);
yield SqliteAlbumConverter.fromSql(
item["album"], f, item["shares"] ?? []);
} catch (e, stackTrace) {
_log.severe(
"[getAll] Failed while converting DB entry", e, stackTrace);
yield ExceptionEvent(e, stackTrace);
}
}
}
}
@override
create(Account account, Album album) async {
_log.info("[create]");
throw UnimplementedError();
}
@override
update(Account account, Album album) async {
_log.info("[update] ${album.albumFile!.path}");
await _c.sqliteDb.use((db) async {
final rowIds =
await db.accountFileRowIdsOf(album.albumFile!, appAccount: account);
final insert = SqliteAlbumConverter.toSql(album, rowIds.fileRowId);
var rowId = await _updateCache(db, rowIds.fileRowId, insert.album);
if (rowId == null) {
// new album, need insert
_log.info("[update] Insert new album");
final insertedAlbum =
await db.into(db.albums).insertReturning(insert.album);
rowId = insertedAlbum.rowId;
} else {
await (db.delete(db.albumShares)..where((t) => t.album.equals(rowId)))
.go();
}
if (insert.albumShares.isNotEmpty) {
await db.batch((batch) {
batch.insertAll(
db.albumShares,
insert.albumShares.map((s) => s.copyWith(album: sql.Value(rowId!))),
);
});
}
});
}
Future<int?> _updateCache(
sql.SqliteDb db, int dbFileRowId, sql.AlbumsCompanion dbAlbum) async {
final rowIdQuery = db.selectOnly(db.albums)
..addColumns([db.albums.rowId])
..where(db.albums.file.equals(dbFileRowId))
..limit(1);
final rowId =
await rowIdQuery.map((r) => r.read(db.albums.rowId)!).getSingleOrNull();
if (rowId == null) {
// new album
return null;
}
await (db.update(db.albums)..where((t) => t.rowId.equals(rowId)))
.write(dbAlbum);
return rowId;
}
final DiContainer _c;
static final _log = Logger("entity.album.AlbumSqliteDbDataSource");
}
class AlbumCachedDataSource implements AlbumDataSource {
AlbumCachedDataSource(DiContainer c)
: _sqliteDbSrc = AlbumSqliteDbDataSource(c);
@override
get(Account account, File albumFile) async {
final result = await getAll(account, [albumFile]).first;
return result as Album;
}
@override
getAll(Account account, List<File> albumFiles) async* {
var i = 0;
await for (final cache in _sqliteDbSrc.getAll(account, albumFiles)) {
final albumFile = albumFiles[i++];
if (_validateCache(cache, albumFile)) {
yield cache;
} else {
// no cache
final remote = await _remoteSrc.get(account, albumFile);
await _cacheResult(account, remote);
yield remote;
}
}
}
@override
update(Account account, Album album) async {
await _remoteSrc.update(account, album);
await _sqliteDbSrc.update(account, album);
}
@override
create(Account account, Album album) => _remoteSrc.create(account, album);
Future<void> _cacheResult(Account account, Album result) {
return _sqliteDbSrc.update(account, result);
}
bool _validateCache(dynamic cache, File albumFile) {
if (cache is Album) {
if (cache.albumFile!.etag?.isNotEmpty == true &&
cache.albumFile!.etag == albumFile.etag) {
// cache is good
_log.fine("[_validateCache] etag matched for ${albumFile.path}");
return true;
} else {
_log.info(
"[_validateCache] Remote content updated for ${albumFile.path}");
return false;
}
} else if (cache is CacheNotFoundException) {
// normal when there's no cache
return false;
} else if (cache is ExceptionEvent) {
_log.shout(
"[_validateCache] Cache failure", cache.error, cache.stackTrace);
return false;
} else {
_log.shout("[_validateCache] Unknown type: ${cache.runtimeType}");
return false;
}
}
final _remoteSrc = AlbumRemoteDataSource();
final AlbumSqliteDbDataSource _sqliteDbSrc;
static final _log = Logger("entity.album.AlbumCachedDataSource");
}

View file

@ -379,7 +379,7 @@ class File with EquatableMixin {
String? path,
int? contentLength,
String? contentType,
String? etag,
OrNull<String>? etag,
DateTime? lastModified,
bool? isCollection,
int? usedBytes,
@ -398,7 +398,7 @@ class File with EquatableMixin {
path: path ?? this.path,
contentLength: contentLength ?? this.contentLength,
contentType: contentType ?? this.contentType,
etag: etag ?? this.etag,
etag: etag == null ? this.etag : etag.obj,
lastModified: lastModified ?? this.lastModified,
isCollection: isCollection ?? this.isCollection,
usedBytes: usedBytes ?? this.usedBytes,
@ -447,7 +447,6 @@ class File with EquatableMixin {
final bool? isCollection;
final int? usedBytes;
final bool? hasPreview;
// maybe null when loaded from old cache
final int? fileId;
final bool? isFavorite;
final CiString? ownerId;

View file

@ -1,15 +1,17 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:idb_shim/idb_client.dart';
import 'package:drift/drift.dart' as sql;
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/file_cache_manager.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/entity/webdav_response_parser.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/iterable_extension.dart';
@ -18,7 +20,6 @@ import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/touch_token_manager.dart';
import 'package:nc_photos/use_case/compat/v32.dart';
import 'package:path/path.dart' as path_lib;
import 'package:tuple/tuple.dart';
import 'package:uuid/uuid.dart';
import 'package:xml/xml.dart';
@ -265,55 +266,38 @@ class FileWebdavDataSource implements FileDataSource {
static final _log = Logger("entity.file.data_source.FileWebdavDataSource");
}
class FileAppDbDataSource implements FileDataSource {
const FileAppDbDataSource(this.appDb);
class FileSqliteDbDataSource implements FileDataSource {
FileSqliteDbDataSource(this._c);
@override
list(Account account, File dir) async {
_log.info("[list] ${dir.path}");
final dbItems = await appDb.use(
(db) => db.transaction(
[AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadOnly),
(transaction) async {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
final dirStore = transaction.objectStore(AppDb.dirStoreName);
final dirItem = await dirStore
.getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?;
if (dirItem == null) {
throw CacheNotFoundException("No entry: ${dir.path}");
}
final dirEntry =
AppDbDirEntry.fromJson(dirItem.cast<String, dynamic>());
return Tuple2(
dirEntry.dir,
await Future.wait(
dirEntry.children.map((c) async {
final fileItem = await fileStore
.getObject(AppDbFile2Entry.toPrimaryKey(account, c)) as Map?;
if (fileItem == null) {
_log.warning(
"[list] Missing file ($c) in db for dir: ${logFilename(dir.path)}");
throw CacheNotFoundException("No entry for dir child: $c");
} else {
return fileItem;
}
}),
eagerError: true,
),
);
},
);
// we need to add dir to match the remote query
return [
dbItems.item1,
...(await dbItems.item2.computeAll(_covertAppDbFile2Entry))
.where((f) => _validateFile(f))
];
final dbFiles = await _c.sqliteDb.use((db) async {
final dbAccount = await db.accountOf(account);
final sql.File dbDir;
try {
dbDir = await db.fileOf(dir, sqlAccount: dbAccount);
} catch (_) {
throw CacheNotFoundException("No entry: ${dir.path}");
}
return await db.completeFilesByDirRowId(dbDir.rowId,
sqlAccount: dbAccount);
});
final results = (await dbFiles.convertToAppFile(account))
.where((f) => _validateFile(f))
.toList();
_log.fine("[list] Queried ${results.length} files");
if (results.isEmpty) {
// each dir will at least contain its own entry, so an empty list here
// means that the dir has not been queried before
throw CacheNotFoundException("No entry: ${dir.path}");
}
return results;
}
@override
listSingle(Account account, File f) {
_log.info("[listSingle] ${f.path}");
_log.severe("[listSingle] ${f.path}");
throw UnimplementedError();
}
@ -322,39 +306,41 @@ class FileAppDbDataSource implements FileDataSource {
Future<List<File>> listByDate(
Account account, int fromEpochMs, int toEpochMs) async {
_log.info("[listByDate] [$fromEpochMs, $toEpochMs]");
final items = await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
final dateTimeEpochMsIndex =
fileStore.index(AppDbFile2Entry.dateTimeEpochMsIndexName);
final range = KeyRange.bound(
AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, fromEpochMs),
AppDbFile2Entry.toDateTimeEpochMsIndexKey(account, toEpochMs),
false,
true,
);
return await dateTimeEpochMsIndex.getAll(range);
},
);
return items
.cast<Map>()
.map((i) => AppDbFile2Entry.fromJson(i.cast<String, dynamic>()))
.map((e) => e.file)
.where((f) => _validateFile(f))
.toList();
final dbFiles = await _c.sqliteDb.use((db) async {
final dateTime = sql.coalesce([
db.accountFiles.overrideDateTime,
db.images.dateTimeOriginal,
db.files.lastModified,
]).secondsSinceEpoch;
final queryBuilder = db.queryFiles()
..setQueryMode(sql.FilesQueryMode.completeFile)
..setAppAccount(account);
final query = queryBuilder.build();
query
..where(dateTime.isBetweenValues(
fromEpochMs ~/ 1000, (toEpochMs ~/ 1000) - 1))
..orderBy([sql.OrderingTerm.desc(dateTime)]);
return await query
.map((r) => sql.CompleteFile(
r.readTable(db.files),
r.readTable(db.accountFiles),
r.readTableOrNull(db.images),
r.readTableOrNull(db.trashes),
))
.get();
});
return await dbFiles.convertToAppFile(account);
}
/// Remove a file/dir from database
@override
remove(Account account, File f) {
_log.info("[remove] ${f.path}");
return FileCacheRemover(appDb)(account, f);
return FileSqliteCacheRemover(_c)(account, f);
}
@override
getBinary(Account account, File f) {
_log.info("[getBinary] ${f.path}");
_log.severe("[getBinary] ${f.path}");
throw UnimplementedError();
}
@ -365,30 +351,51 @@ class FileAppDbDataSource implements FileDataSource {
}
@override
updateProperty(
Account account,
File f, {
OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime,
bool? favorite,
}) {
updateProperty(Account account, File f,
{OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime,
bool? favorite}) async {
_log.info("[updateProperty] ${f.path}");
return appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite),
(transaction) async {
// update file store
final newFile = f.copyWith(
metadata: metadata,
isArchived: isArchived,
overrideDateTime: overrideDateTime,
isFavorite: favorite,
await _c.sqliteDb.use((db) async {
final rowIds = await db.accountFileRowIdsOf(f, appAccount: account);
if (isArchived != null || overrideDateTime != null || favorite != null) {
final update = sql.AccountFilesCompanion(
isArchived: isArchived == null
? const sql.Value.absent()
: sql.Value(isArchived.obj),
overrideDateTime: overrideDateTime == null
? const sql.Value.absent()
: sql.Value(overrideDateTime.obj),
isFavorite:
favorite == null ? const sql.Value.absent() : sql.Value(favorite),
);
final fileStore = transaction.objectStore(AppDb.file2StoreName);
await fileStore.put(AppDbFile2Entry.fromFile(account, newFile).toJson(),
AppDbFile2Entry.toPrimaryKeyForFile(account, newFile));
},
);
await (db.update(db.accountFiles)
..where((t) => t.rowId.equals(rowIds.accountFileRowId)))
.write(update);
}
if (metadata != null) {
if (metadata.obj == null) {
await (db.delete(db.images)
..where((t) => t.accountFile.equals(rowIds.accountFileRowId)))
.go();
} else {
await db
.into(db.images)
.insertOnConflictUpdate(sql.ImagesCompanion.insert(
accountFile: sql.Value(rowIds.accountFileRowId),
lastUpdated: metadata.obj!.lastUpdated,
fileEtag: sql.Value(metadata.obj!.fileEtag),
width: sql.Value(metadata.obj!.imageWidth),
height: sql.Value(metadata.obj!.imageHeight),
exifRaw: sql.Value(
metadata.obj!.exif?.toJson().run((j) => jsonEncode(j))),
dateTimeOriginal:
sql.Value(metadata.obj!.exif?.dateTimeOriginal),
));
}
}
});
}
@override
@ -416,23 +423,23 @@ class FileAppDbDataSource implements FileDataSource {
// do nothing
}
final AppDb appDb;
final DiContainer _c;
static final _log = Logger("entity.file.data_source.FileAppDbDataSource");
static final _log = Logger("entity.file.data_source.FileSqliteDbDataSource");
}
class FileCachedDataSource implements FileDataSource {
FileCachedDataSource(
this.appDb, {
this._c, {
this.shouldCheckCache = false,
this.forwardCacheManager,
}) : _appDbSrc = FileAppDbDataSource(appDb);
}) : _sqliteDbSrc = FileSqliteDbDataSource(_c);
@override
list(Account account, File dir) async {
final cacheLoader = FileCacheLoader(
appDb: appDb,
appDbSrc: _appDbSrc,
_c,
cacheSrc: _sqliteDbSrc,
remoteSrc: _remoteSrc,
shouldCheckCache: shouldCheckCache,
forwardCacheManager: forwardCacheManager,
@ -445,7 +452,7 @@ class FileCachedDataSource implements FileDataSource {
// no cache or outdated
try {
final remote = await _remoteSrc.list(account, dir);
await FileCacheUpdater(appDb)(account, dir, remote: remote, cache: cache);
await FileSqliteCacheUpdater(_c)(account, dir, remote: remote);
if (shouldCheckCache) {
// update our local touch token to match the remote one
const tokenManager = TouchTokenManager();
@ -461,11 +468,15 @@ class FileCachedDataSource implements FileDataSource {
} on ApiException catch (e) {
if (e.response.statusCode == 404) {
_log.info("[list] File removed: $dir");
_appDbSrc.remove(account, dir);
if (cache != null) {
await _sqliteDbSrc.remove(account, dir);
}
return [];
} else if (e.response.statusCode == 403) {
_log.info("[list] E2E encrypted dir: $dir");
_appDbSrc.remove(account, dir);
if (cache != null) {
await _sqliteDbSrc.remove(account, dir);
}
return [];
} else {
rethrow;
@ -480,7 +491,7 @@ class FileCachedDataSource implements FileDataSource {
@override
remove(Account account, File f) async {
await _appDbSrc.remove(account, f);
await _sqliteDbSrc.remove(account, f);
await _remoteSrc.remove(account, f);
}
@ -503,23 +514,22 @@ class FileCachedDataSource implements FileDataSource {
OrNull<DateTime>? overrideDateTime,
bool? favorite,
}) async {
await _remoteSrc
.updateProperty(
account,
f,
metadata: metadata,
isArchived: isArchived,
overrideDateTime: overrideDateTime,
favorite: favorite,
)
.then((_) => _appDbSrc.updateProperty(
account,
f,
metadata: metadata,
isArchived: isArchived,
overrideDateTime: overrideDateTime,
favorite: favorite,
));
await _remoteSrc.updateProperty(
account,
f,
metadata: metadata,
isArchived: isArchived,
overrideDateTime: overrideDateTime,
favorite: favorite,
);
await _sqliteDbSrc.updateProperty(
account,
f,
metadata: metadata,
isArchived: isArchived,
overrideDateTime: overrideDateTime,
favorite: favorite,
);
// generate a new random token
final token = const Uuid().v4().replaceAll("-", "");
@ -559,12 +569,12 @@ class FileCachedDataSource implements FileDataSource {
await _remoteSrc.createDir(account, path);
}
final AppDb appDb;
final DiContainer _c;
final bool shouldCheckCache;
final FileForwardCacheManager? forwardCacheManager;
final _remoteSrc = const FileWebdavDataSource();
final FileAppDbDataSource _appDbSrc;
final FileSqliteDbDataSource _sqliteDbSrc;
static final _log = Logger("entity.file.data_source.FileCachedDataSource");
}
@ -576,7 +586,11 @@ class FileCachedDataSource implements FileDataSource {
/// passed to us in one transaction. For this reason, this should only be used
/// when it's necessary to query everything
class FileForwardCacheManager {
FileForwardCacheManager(this.appDb, this.knownFiles);
FileForwardCacheManager(this._c, Map<int, File> knownFiles) {
_fileCache.addAll(knownFiles);
}
static bool require(DiContainer c) => true;
/// Transform a list of files to a map suitable to be passed as the
/// [knownFiles] argument
@ -587,117 +601,155 @@ class FileForwardCacheManager {
Future<List<File>> list(Account account, File dir) async {
// check cache
final dirKey = AppDbDirEntry.toPrimaryKeyForDir(account, dir);
final cachedDir = _dirCache[dirKey];
if (cachedDir != null) {
var childFileIds = _dirCache[dir.strippedPathWithEmpty];
if (childFileIds == null) {
_log.info(
"[list] No cache and querying everything under ${logFilename(dir.path)}");
await _cacheDir(account, dir);
childFileIds = _dirCache[dir.strippedPathWithEmpty];
if (childFileIds == null) {
throw CacheNotFoundException("No entry: ${dir.path}");
}
} else {
_log.fine("[list] Returning data from cache: ${logFilename(dir.path)}");
return _withDirEntry(cachedDir);
}
// no cache, query everything under [dir]
_log.info(
"[list] No cache and querying everything under ${logFilename(dir.path)}");
await _cacheDir(account, dir);
final cachedDir2 = _dirCache[dirKey];
if (cachedDir2 == null) {
throw CacheNotFoundException("No entry: ${dir.path}");
}
return _withDirEntry(cachedDir2);
return _listByFileIds(dir, childFileIds);
}
Future<void> _cacheDir(Account account, File dir) async {
final dirItems = await appDb.use(
(db) => db.transaction(AppDb.dirStoreName, idbModeReadOnly),
(transaction) async {
final store = transaction.objectStore(AppDb.dirStoreName);
final dirItem = await store
.getObject(AppDbDirEntry.toPrimaryKeyForDir(account, dir)) as Map?;
if (dirItem == null) {
return null;
}
final range = KeyRange.bound(
AppDbDirEntry.toPrimaryLowerKeyForSubDirs(account, dir),
AppDbDirEntry.toPrimaryUpperKeyForSubDirs(account, dir),
);
return [dirItem] + (await store.getAll(range)).cast<Map>();
},
);
if (dirItems == null) {
await _c.sqliteDb.use((db) async {
final dbAccount = await db.accountOf(account);
final dirCache = <String, List<int>>{};
await _fillDirCacheForDir(
db, dirCache, dbAccount, null, dir.fileId, dir.strippedPathWithEmpty);
_log.info(
"[_cacheDir] Cached ${dirCache.length} dirs under ${logFilename(dir.path)}");
await _fillFileCache(
db, account, dbAccount, dirCache.values.flatten().toSet());
_dirCache.addAll(dirCache);
});
}
Future<void> _fillDirCacheForDir(
sql.SqliteDb db,
Map<String, List<int>> dirCache,
sql.Account dbAccount,
int? dirRowId,
int? dirFileId,
String dirRelativePath) async {
// get rowId
final myDirRowId = dirRowId ??
await _queryFileIdOfDir(db, dbAccount, dirFileId, dirRelativePath);
if (myDirRowId == null) {
// no cache
return;
}
final dirs = dirItems
.map((i) => AppDbDirEntry.fromJson(i.cast<String, dynamic>()))
.toList();
_dirCache.addEntries(dirs.map(
(e) => MapEntry(AppDbDirEntry.toPrimaryKeyForDir(account, e.dir), e)));
_log.info(
"[_cacheDir] Cached ${dirs.length} dirs under ${logFilename(dir.path)}");
// cache files
final fileIds = dirs.map((e) => e.children).fold<List<int>>(
[], (previousValue, element) => previousValue + element);
final needQuery = <int>[];
final files = <File>[];
// check files already known to us
if (knownFiles.isNotEmpty) {
for (final id in fileIds) {
final f = knownFiles[id];
if (f != null) {
files.add(f);
} else {
needQuery.add(id);
}
}
} else {
needQuery.addAll(fileIds);
final children = await _queryChildOfDir(db, dbAccount, myDirRowId);
if (children.isEmpty) {
// no cache
return;
}
_log.info(
"[_cacheDir] ${files.length} files known, ${needQuery.length} files need querying");
final childFileIds = children.map((c) => c.fileId).toList();
dirCache[dirRelativePath] = childFileIds;
// query other files
// recursively fill child dirs
for (final c in children
.where((c) => c.rowId != myDirRowId && c.isCollection == true)) {
await _fillDirCacheForDir(
db, dirCache, dbAccount, c.rowId, c.fileId, c.relativePath);
}
}
Future<void> _fillFileCache(sql.SqliteDb db, Account account,
sql.Account dbAccount, Iterable<int> fileIds) async {
final needQuery = fileIds.where((id) => !_fileCache.containsKey(id));
if (needQuery.isNotEmpty) {
final dbItems = await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async {
final store = transaction.objectStore(AppDb.file2StoreName);
return await Future.wait(needQuery.map((id) =>
store.getObject(AppDbFile2Entry.toPrimaryKey(account, id))));
},
);
files.addAll(
await dbItems.whereType<Map>().computeAll(_covertAppDbFile2Entry));
_log.info("[_fillFileCache] ${needQuery.length} files need querying");
final dbFiles =
await db.completeFilesByFileIds(needQuery, sqlAccount: dbAccount);
for (final f in await dbFiles.convertToAppFile(account)) {
_fileCache[f.fileId!] = f;
}
_log.info("[_fillFileCache] Cached ${dbFiles.length} files");
} else {
_log.info("[_fillFileCache] 0 files need querying");
}
_fileCache.addEntries(files.map((f) => MapEntry(f.fileId!, f)));
_log.info(
"[_cacheDir] Cached ${files.length} files under ${logFilename(dir.path)}");
}
List<File> _withDirEntry(AppDbDirEntry dirEntry) {
return [dirEntry.dir] +
dirEntry.children.map((id) {
try {
return _fileCache[id]!;
} catch (_) {
_log.warning(
"[list] Missing file ($id) in db for dir: ${logFilename(dirEntry.dir.path)}");
throw CacheNotFoundException("No entry for dir child: $id");
}
}).toList();
List<File> _listByFileIds(File dir, List<int> childFileIds) {
return childFileIds.map((id) {
try {
return _fileCache[id]!;
} catch (_) {
_log.warning(
"[_listByFileIds] Missing file ($id) in db for dir: ${logFilename(dir.path)}");
throw CacheNotFoundException("No entry for dir child: $id");
}
}).toList();
}
final AppDb appDb;
final Map<int, File> knownFiles;
final _dirCache = <String, AppDbDirEntry>{};
Future<int?> _queryFileIdOfDir(sql.SqliteDb db, sql.Account dbAccount,
int? dirFileId, String dirRelativePath) async {
final dirQuery = db.queryFiles().run((q) {
q
..setQueryMode(sql.FilesQueryMode.expression,
expressions: [db.files.rowId])
..setSqlAccount(dbAccount);
if (dirFileId != null) {
q.byFileId(dirFileId);
} else {
q.byRelativePath(dirRelativePath);
}
return q.build()..limit(1);
});
return await dirQuery.map((r) => r.read(db.files.rowId)).getSingleOrNull();
}
Future<List<_ForwardCacheQueryChildResult>> _queryChildOfDir(
sql.SqliteDb db, sql.Account dbAccount, int dirRowId) async {
final childQuery = db.selectOnly(db.files).join([
sql.innerJoin(db.dirFiles, db.dirFiles.child.equalsExp(db.files.rowId),
useColumns: false),
sql.innerJoin(
db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId),
useColumns: false),
])
..addColumns([
db.files.rowId,
db.files.fileId,
db.files.isCollection,
db.accountFiles.relativePath,
])
..where(db.dirFiles.dir.equals(dirRowId))
..where(db.accountFiles.account.equals(dbAccount.rowId));
return await childQuery
.map((r) => _ForwardCacheQueryChildResult(
r.read(db.files.rowId)!,
r.read(db.files.fileId)!,
r.read(db.accountFiles.relativePath)!,
r.read(db.files.isCollection),
))
.get();
}
final DiContainer _c;
final _dirCache = <String, List<int>>{};
final _fileCache = <int, File>{};
static final _log = Logger("entity.file.data_source.FileForwardCacheManager");
}
class _ForwardCacheQueryChildResult {
const _ForwardCacheQueryChildResult(
this.rowId, this.fileId, this.relativePath, this.isCollection);
final int rowId;
final int fileId;
final String relativePath;
final bool? isCollection;
}
bool _validateFile(File f) {
// See: https://gitlab.com/nkming2/nc-photos/-/issues/9
return f.lastModified != null;
}
File _covertAppDbFile2Entry(Map json) =>
AppDbFile2Entry.fromJson(json.cast<String, dynamic>()).file;

View file

@ -1,25 +1,28 @@
import 'package:collection/collection.dart';
import 'package:idb_shim/idb_client.dart';
import 'package:drift/drift.dart' as sql;
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/list_util.dart' as list_util;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/remote_storage_util.dart' as remote_storage_util;
import 'package:nc_photos/touch_token_manager.dart';
class FileCacheLoader {
FileCacheLoader({
required this.appDb,
required this.appDbSrc,
FileCacheLoader(
this._c, {
required this.cacheSrc,
required this.remoteSrc,
this.shouldCheckCache = false,
this.forwardCacheManager,
});
}) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo);
/// Return the cached results of listing a directory [dir]
///
@ -30,7 +33,7 @@ class FileCacheLoader {
if (forwardCacheManager != null) {
cache = await forwardCacheManager!.list(account, dir);
} else {
cache = await appDbSrc.list(account, dir);
cache = await cacheSrc.list(account, dir);
}
// compare the cached root
final cacheEtag =
@ -40,8 +43,6 @@ class FileCacheLoader {
// if no etag supplied, we need to query it form remote
remoteEtag ??= (await remoteSrc.list(account, dir, depth: 0)).first.etag;
if (cacheEtag == remoteEtag) {
_log.fine(
"[list] etag matched for ${AppDbDirEntry.toPrimaryKeyForDir(account, dir)}");
if (shouldCheckCache) {
await _checkTouchToken(account, dir, cache);
} else {
@ -65,11 +66,10 @@ class FileCacheLoader {
Account account, File f, List<File> cache) async {
final touchPath =
"${remote_storage_util.getRemoteTouchDir(account)}/${f.strippedPath}";
final fileRepo = FileRepo(FileCachedDataSource(appDb));
const tokenManager = TouchTokenManager();
String? remoteToken;
try {
remoteToken = await tokenManager.getRemoteToken(fileRepo, account, f);
remoteToken = await tokenManager.getRemoteToken(_c.fileRepo, account, f);
} catch (e, stacktrace) {
_log.shout(
"[_checkTouchToken] Failed getting remote token at '$touchPath'",
@ -96,9 +96,9 @@ class FileCacheLoader {
}
}
final AppDb appDb;
final DiContainer _c;
final FileWebdavDataSource remoteSrc;
final FileAppDbDataSource appDbSrc;
final FileDataSource cacheSrc;
final bool shouldCheckCache;
final FileForwardCacheManager? forwardCacheManager;
@ -108,215 +108,264 @@ class FileCacheLoader {
static final _log = Logger("entity.file.file_cache_manager.FileCacheLoader");
}
class FileCacheUpdater {
const FileCacheUpdater(this.appDb);
class FileSqliteCacheUpdater {
FileSqliteCacheUpdater(this._c) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
Future<void> call(
Account account,
File dir, {
required List<File> remote,
List<File>? cache,
}) async {
await _cacheRemote(account, dir, remote);
if (cache != null) {
await _cleanUpCache(account, remote, cache);
final s = Stopwatch()..start();
try {
await _cacheRemote(account, dir, remote);
} finally {
_log.info("[call] Elapsed time: ${s.elapsedMilliseconds}ms");
}
}
Future<void> _cacheRemote(
Account account, File dir, List<File> remote) async {
final s = Stopwatch()..start();
try {
await appDb.use(
(db) => db.transaction(
[AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite),
(transaction) async {
final dirStore = transaction.objectStore(AppDb.dirStoreName);
final fileStore = transaction.objectStore(AppDb.file2StoreName);
final sqlFiles = await remote.convertToFileCompanion(null);
await _c.sqliteDb.use((db) async {
final dbAccount = await db.accountOf(account);
final inserts = await _updateCache(db, dbAccount, sqlFiles, remote, dir);
if (inserts.isNotEmpty) {
await _insertCache(db, dbAccount, inserts, dir);
}
if (_dirRowId == null) {
_log.severe("[_cacheRemote] Dir not inserted");
throw StateError("Row ID for dir is null");
}
// add files to db
await Future.wait(remote.map((f) => fileStore.put(
AppDbFile2Entry.fromFile(account, f).toJson(),
AppDbFile2Entry.toPrimaryKeyForFile(account, f))));
// results from remote also contain the dir itself
final resultGroup =
remote.groupListsBy((f) => f.compareServerIdentity(dir));
final remoteDir = resultGroup[true]!.first;
final remoteChildren = resultGroup[false] ?? [];
// add dir to db
await dirStore.put(
AppDbDirEntry.fromFiles(account, remoteDir, remoteChildren)
.toJson(),
AppDbDirEntry.toPrimaryKeyForDir(account, remoteDir));
},
);
} finally {
_log.info("[_cacheRemote] Elapsed time: ${s.elapsedMilliseconds}ms");
}
final dirChildRowIdQuery = db.selectOnly(db.dirFiles)
..addColumns([db.dirFiles.child])
..where(db.dirFiles.dir.equals(_dirRowId))
..orderBy([sql.OrderingTerm.asc(db.dirFiles.rowId)]);
final dirChildRowIds =
await dirChildRowIdQuery.map((r) => r.read(db.dirFiles.child)!).get();
final diff = list_util.diff(dirChildRowIds, _childRowIds.sorted());
if (diff.item1.isNotEmpty) {
await db.batch((batch) {
// insert new children
batch.insertAll(db.dirFiles,
diff.item1.map((k) => sql.DirFile(dir: _dirRowId!, child: k)));
});
}
if (diff.item2.isNotEmpty) {
// delete obsolete children
await _removeSqliteFiles(db, dbAccount, diff.item2);
await _cleanUpRemovedFile(db, dbAccount);
}
});
}
/// Remove extra entries from local cache based on remote contents
Future<void> _cleanUpCache(
Account account, List<File> remote, List<File> cache) async {
final removed = cache
.where((c) => !remote.any((r) => r.compareServerIdentity(c)))
.toList();
if (removed.isEmpty) {
return;
}
_log.info(
"[_cleanUpCache] Removed: ${removed.map((f) => f.path).toReadableString()}");
/// Update Db files in [sqlFiles]
///
/// Return a list of DB files that are not yet inserted to the DB (thus not
/// possible to update)
Future<List<sql.CompleteFileCompanion>> _updateCache(
sql.SqliteDb db,
sql.Account dbAccount,
Iterable<sql.CompleteFileCompanion> sqlFiles,
Iterable<File> remoteFiles,
File dir,
) async {
// query list of rowIds for files in [remoteFiles]
final rowIds = await db.accountFileRowIdsByFileIds(
remoteFiles.map((f) => f.fileId!),
sqlAccount: dbAccount,
);
final rowIdsMap = Map.fromEntries(rowIds.map((e) => MapEntry(e.fileId, e)));
await appDb.use(
(db) => db.transaction(
[AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite),
(transaction) async {
final dirStore = transaction.objectStore(AppDb.dirStoreName);
final fileStore = transaction.objectStore(AppDb.file2StoreName);
for (final f in removed) {
try {
if (f.isCollection == true) {
await _removeDirFromAppDb(account, f,
dirStore: dirStore, fileStore: fileStore);
} else {
await _removeFileFromAppDb(account, f, fileStore: fileStore);
}
} catch (e, stackTrace) {
_log.shout(
"[_cleanUpCache] Failed while removing file: ${logFilename(f.path)}",
e,
stackTrace);
final inserts = <sql.CompleteFileCompanion>[];
// for updates, we use batch to speed up the process
await db.batch((batch) {
for (final f in sqlFiles) {
final thisRowIds = rowIdsMap[f.file.fileId.value];
if (thisRowIds != null) {
// updates
batch.update(
db.files,
f.file,
where: (sql.$FilesTable t) => t.rowId.equals(thisRowIds.fileRowId),
);
batch.update(
db.accountFiles,
f.accountFile,
where: (sql.$AccountFilesTable t) =>
t.rowId.equals(thisRowIds.accountFileRowId),
);
if (f.image != null) {
batch.update(
db.images,
f.image!,
where: (sql.$ImagesTable t) =>
t.accountFile.equals(thisRowIds.accountFileRowId),
);
}
if (f.trash != null) {
batch.update(
db.trashes,
f.trash!,
where: (sql.$TrashesTable t) =>
t.file.equals(thisRowIds.fileRowId),
);
}
_onRowCached(thisRowIds.fileRowId, f, dir);
} else {
// inserts, do it later
inserts.add(f);
}
},
}
});
_log.info(
"[_updateCache] Updated ${sqlFiles.length - inserts.length} files");
return inserts;
}
Future<void> _insertCache(sql.SqliteDb db, sql.Account dbAccount,
List<sql.CompleteFileCompanion> sqlFiles, File dir) async {
_log.info("[_insertCache] Insert ${sqlFiles.length} files");
// check if the files exist in the db in other accounts
final query = db.queryFiles().run((q) {
q
..setQueryMode(
sql.FilesQueryMode.expression,
expressions: [db.files.rowId, db.files.fileId],
)
..setAccountless()
..byServerRowId(dbAccount.server)
..byFileIds(sqlFiles.map((f) => f.file.fileId.value));
return q.build();
});
final fileRowIdMap = Map.fromEntries(await query
.map((r) => MapEntry(r.read(db.files.fileId)!, r.read(db.files.rowId)!))
.get());
await Future.wait(
sqlFiles.map((f) async {
var rowId = fileRowIdMap[f.file.fileId.value];
if (rowId != null) {
// shared file that exists in other accounts
} else {
final dbFile = await db.into(db.files).insertReturning(
f.file.copyWith(server: sql.Value(dbAccount.server)),
);
rowId = dbFile.rowId;
}
final dbAccountFile = await db
.into(db.accountFiles)
.insertReturning(f.accountFile.copyWith(
account: sql.Value(dbAccount.rowId),
file: sql.Value(rowId),
));
if (f.image != null) {
await db.into(db.images).insert(
f.image!.copyWith(accountFile: sql.Value(dbAccountFile.rowId)));
}
if (f.trash != null) {
await db
.into(db.trashes)
.insert(f.trash!.copyWith(file: sql.Value(rowId)));
}
_onRowCached(rowId, f, dir);
}),
eagerError: true,
);
}
final AppDb appDb;
void _onRowCached(int rowId, sql.CompleteFileCompanion dbFile, File dir) {
if (_compareIdentity(dbFile, dir)) {
_dirRowId = rowId;
}
_childRowIds.add(rowId);
}
static final _log = Logger("entity.file.file_cache_manager.FileCacheUpdater");
bool _compareIdentity(sql.CompleteFileCompanion dbFile, File appFile) {
if (appFile.fileId != null) {
return appFile.fileId == dbFile.file.fileId.value;
} else {
return appFile.strippedPathWithEmpty ==
dbFile.accountFile.relativePath.value;
}
}
final DiContainer _c;
int? _dirRowId;
final _childRowIds = <int>[];
static final _log =
Logger("entity.file.file_cache_manager.FileSqliteCacheUpdater");
}
class FileCacheRemover {
const FileCacheRemover(this.appDb);
class FileSqliteCacheRemover {
FileSqliteCacheRemover(this._c) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
/// Remove a file/dir from cache
///
/// If [f] is a dir, the dir and its sub-dirs will be removed from dirStore.
/// The files inside any of these dirs will be removed from file2Store.
///
/// If [f] is a file, the file will be removed from file2Store, but no changes
/// to dirStore.
Future<void> call(Account account, File f) async {
if (f.isCollection != false) {
// removing dir is basically a superset of removing file, so we'll treat
// unspecified file as dir
await appDb.use(
(db) => db.transaction(
[AppDb.dirStoreName, AppDb.file2StoreName], idbModeReadWrite),
(transaction) async {
final dirStore = transaction.objectStore(AppDb.dirStoreName);
final fileStore = transaction.objectStore(AppDb.file2StoreName);
await _removeDirFromAppDb(account, f,
dirStore: dirStore, fileStore: fileStore);
},
);
} else {
await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite),
(transaction) async {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
await _removeFileFromAppDb(account, f, fileStore: fileStore);
},
);
}
}
final AppDb appDb;
}
Future<void> _removeFileFromAppDb(
Account account,
File file, {
required ObjectStore fileStore,
}) async {
try {
if (file.fileId == null) {
final index = fileStore.index(AppDbFile2Entry.strippedPathIndexName);
final key = await index
.getKey(AppDbFile2Entry.toStrippedPathIndexKeyForFile(account, file));
if (key != null) {
_log.fine("[_removeFileFromAppDb] Removing fileStore entry: $key");
await fileStore.delete(key);
}
} else {
await AppDbFile2Entry.toPrimaryKeyForFile(account, file).run((key) {
_log.fine("[_removeFileFromAppDb] Removing fileStore entry: $key");
return fileStore.delete(key);
});
}
} catch (e, stackTrace) {
_log.shout(
"[_removeFileFromAppDb] Failed removing fileStore entry: ${logFilename(file.path)}",
e,
stackTrace);
}
}
/// Remove a dir and all files inside from the database
Future<void> _removeDirFromAppDb(
Account account,
File dir, {
required ObjectStore dirStore,
required ObjectStore fileStore,
}) async {
// delete the dir itself
try {
await AppDbDirEntry.toPrimaryKeyForDir(account, dir).run((key) {
_log.fine("[_removeDirFromAppDb] Removing dirStore entry: $key");
return dirStore.delete(key);
await _c.sqliteDb.use((db) async {
final dbAccount = await db.accountOf(account);
final rowIds = await db.accountFileRowIdsOf(f, sqlAccount: dbAccount);
await _removeSqliteFiles(db, dbAccount, [rowIds.fileRowId]);
await _cleanUpRemovedFile(db, dbAccount);
});
} catch (e, stackTrace) {
if (dir.isCollection != null) {
_log.shout("[_removeDirFromAppDb] Failed removing dirStore entry", e,
stackTrace);
}
}
// then its children
final childrenRange = KeyRange.bound(
AppDbDirEntry.toPrimaryLowerKeyForSubDirs(account, dir),
AppDbDirEntry.toPrimaryUpperKeyForSubDirs(account, dir),
);
for (final key in await dirStore.getAllKeys(childrenRange)) {
_log.fine("[_removeDirFromAppDb] Removing dirStore entry: $key");
try {
await dirStore.delete(key);
} catch (e, stackTrace) {
_log.shout("[_removeDirFromAppDb] Failed removing dirStore entry", e,
stackTrace);
}
}
// delete files from fileStore
// first the dir
await _removeFileFromAppDb(account, dir, fileStore: fileStore);
// then files under this dir and sub-dirs
final range = KeyRange.bound(
AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, dir),
AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, dir),
);
final strippedPathIndex =
fileStore.index(AppDbFile2Entry.strippedPathIndexName);
for (final key in await strippedPathIndex.getAllKeys(range)) {
_log.fine("[_removeDirFromAppDb] Removing fileStore entry: $key");
try {
await fileStore.delete(key);
} catch (e, stackTrace) {
_log.shout("[_removeDirFromAppDb] Failed removing fileStore entry", e,
stackTrace);
}
final DiContainer _c;
}
/// Remove a files from the cache db
///
/// If a file is a dir, its children will also be recursively removed
Future<void> _removeSqliteFiles(
sql.SqliteDb db, sql.Account dbAccount, List<int> fileRowIds) async {
// query list of children, in case some of the files are dirs
final childQuery = db.selectOnly(db.dirFiles)
..addColumns([db.dirFiles.child])
..where(db.dirFiles.dir.isIn(fileRowIds));
final childRowIds =
await childQuery.map((r) => r.read(db.dirFiles.child)!).get();
childRowIds.removeWhere((id) => fileRowIds.contains(id));
// remove the files in AccountFiles table. We are not removing in Files table
// because a file could be associated with multiple accounts
await (db.delete(db.accountFiles)
..where(
(t) => t.account.equals(dbAccount.rowId) & t.file.isIn(fileRowIds)))
.go();
if (childRowIds.isNotEmpty) {
// remove children recursively
return _removeSqliteFiles(db, dbAccount, childRowIds);
} else {
return;
}
}
final _log = Logger("entity.file.file_cache_manager");
Future<void> _cleanUpRemovedFile(sql.SqliteDb db, sql.Account dbAccount) async {
// delete dangling files: entries in Files w/o a corresponding entry in
// AccountFiles
final danglingFileQuery = db.selectOnly(db.files).join([
sql.leftOuterJoin(
db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId),
useColumns: false),
])
..addColumns([db.files.rowId])
..where(db.accountFiles.relativePath.isNull());
final danglingFileRowIds =
await danglingFileQuery.map((r) => r.read(db.files.rowId)!).get();
if (danglingFileRowIds.isNotEmpty) {
__log.info(
"[_cleanUpRemovedFile] Delete ${danglingFileRowIds.length} files");
await (db.delete(db.files)..where((t) => t.rowId.isIn(danglingFileRowIds)))
.go();
}
}
final __log = Logger("entity.file.file_cache_manager");

View file

@ -0,0 +1,209 @@
import 'package:drift/drift.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
part 'sqlite_table.g.dart';
class Servers extends Table {
IntColumn get rowId => integer().autoIncrement()();
TextColumn get address => text().unique()();
}
class Accounts extends Table {
IntColumn get rowId => integer().autoIncrement()();
IntColumn get server =>
integer().references(Servers, #rowId, onDelete: KeyAction.cascade)();
TextColumn get userId => text()();
@override
get uniqueKeys => [
{server, userId},
];
}
/// A file located on a server
class Files extends Table {
IntColumn get rowId => integer().autoIncrement()();
IntColumn get server =>
integer().references(Servers, #rowId, onDelete: KeyAction.cascade)();
IntColumn get fileId => integer()();
IntColumn get contentLength => integer().nullable()();
TextColumn get contentType => text().nullable()();
TextColumn get etag => text().nullable()();
DateTimeColumn get lastModified =>
dateTime().map(const _DateTimeConverter()).nullable()();
BoolColumn get isCollection => boolean().nullable()();
IntColumn get usedBytes => integer().nullable()();
BoolColumn get hasPreview => boolean().nullable()();
TextColumn get ownerId => text().nullable()();
@override
get uniqueKeys => [
{server, fileId},
];
}
/// Account specific properties associated with a file
///
/// A file on a Nextcloud server can have more than 1 path when it's shared
class AccountFiles extends Table {
IntColumn get rowId => integer().autoIncrement()();
IntColumn get account =>
integer().references(Accounts, #rowId, onDelete: KeyAction.cascade)();
IntColumn get file =>
integer().references(Files, #rowId, onDelete: KeyAction.cascade)();
TextColumn get relativePath => text()();
BoolColumn get isFavorite => boolean().nullable()();
BoolColumn get isArchived => boolean().nullable()();
DateTimeColumn get overrideDateTime =>
dateTime().map(const _DateTimeConverter()).nullable()();
@override
get uniqueKeys => [
{account, file},
];
}
/// An image file
class Images extends Table {
// image data technically is identical between accounts, but the way it's
// stored in the server is account specific so we follow the server here
IntColumn get accountFile =>
integer().references(AccountFiles, #rowId, onDelete: KeyAction.cascade)();
DateTimeColumn get lastUpdated =>
dateTime().map(const _DateTimeConverter())();
TextColumn get fileEtag => text().nullable()();
IntColumn get width => integer().nullable()();
IntColumn get height => integer().nullable()();
TextColumn get exifRaw => text().nullable()();
// exif columns
DateTimeColumn get dateTimeOriginal =>
dateTime().map(const _DateTimeConverter()).nullable()();
@override
get primaryKey => {accountFile};
}
/// A file inside trashbin
@DataClassName("Trash")
class Trashes extends Table {
IntColumn get file =>
integer().references(Files, #rowId, onDelete: KeyAction.cascade)();
TextColumn get filename => text()();
TextColumn get originalLocation => text()();
DateTimeColumn get deletionTime =>
dateTime().map(const _DateTimeConverter())();
@override
get primaryKey => {file};
}
/// A file located under another dir (dir is also a file)
class DirFiles extends Table {
IntColumn get dir =>
integer().references(Files, #rowId, onDelete: KeyAction.cascade)();
IntColumn get child =>
integer().references(Files, #rowId, onDelete: KeyAction.cascade)();
@override
get primaryKey => {dir, child};
}
class Albums extends Table {
IntColumn get rowId => integer().autoIncrement()();
IntColumn get file => integer()
.references(Files, #rowId, onDelete: KeyAction.cascade)
.unique()();
IntColumn get version => integer()();
DateTimeColumn get lastUpdated =>
dateTime().map(const _DateTimeConverter())();
TextColumn get name => text()();
// provider
TextColumn get providerType => text()();
TextColumn get providerContent => text()();
// cover provider
TextColumn get coverProviderType => text()();
TextColumn get coverProviderContent => text()();
// sort provider
TextColumn get sortProviderType => text()();
TextColumn get sortProviderContent => text()();
}
class AlbumShares extends Table {
IntColumn get album =>
integer().references(Albums, #rowId, onDelete: KeyAction.cascade)();
TextColumn get userId => text()();
TextColumn get displayName => text().nullable()();
DateTimeColumn get sharedAt => dateTime().map(const _DateTimeConverter())();
@override
get primaryKey => {album, userId};
}
@DriftDatabase(
tables: [
Servers,
Accounts,
Files,
Images,
Trashes,
AccountFiles,
DirFiles,
Albums,
AlbumShares,
],
)
class SqliteDb extends _$SqliteDb {
SqliteDb({
QueryExecutor? executor,
}) : super(executor ?? platform.openSqliteConnection());
SqliteDb.connect(DatabaseConnection connection) : super.connect(connection);
@override
get schemaVersion => 1;
@override
get migration => MigrationStrategy(
onCreate: (m) async {
await m.createAll();
await m.createIndex(Index("files_server_index",
"CREATE INDEX files_server_index ON files(server);"));
await m.createIndex(Index("files_file_id_index",
"CREATE INDEX files_file_id_index ON files(file_id);"));
await m.createIndex(Index("files_content_type_index",
"CREATE INDEX files_content_type_index ON files(content_type);"));
await m.createIndex(Index("account_files_file_index",
"CREATE INDEX account_files_file_index ON account_files(file);"));
await m.createIndex(Index("account_files_relative_path_index",
"CREATE INDEX account_files_relative_path_index ON account_files(relative_path);"));
await m.createIndex(Index("dir_files_dir_index",
"CREATE INDEX dir_files_dir_index ON dir_files(dir);"));
await m.createIndex(Index("dir_files_child_index",
"CREATE INDEX dir_files_child_index ON dir_files(child);"));
await m.createIndex(Index("album_shares_album_index",
"CREATE INDEX album_shares_album_index ON album_shares(album);"));
},
beforeOpen: (details) async {
await customStatement("PRAGMA foreign_keys = ON");
},
);
}
class _DateTimeConverter extends TypeConverter<DateTime, DateTime> {
const _DateTimeConverter();
@override
DateTime? mapToDart(DateTime? fromDb) => fromDb?.toUtc();
@override
DateTime? mapToSql(DateTime? value) => value?.toUtc();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,141 @@
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/cover_provider.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/entity/exif.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/object_extension.dart';
class SqliteAlbumConverter {
static Album fromSql(
sql.Album album, File albumFile, List<sql.AlbumShare> shares) {
return Album(
lastUpdated: album.lastUpdated,
name: album.name,
provider: AlbumProvider.fromJson({
"type": album.providerType,
"content": jsonDecode(album.providerContent),
}),
coverProvider: AlbumCoverProvider.fromJson({
"type": album.coverProviderType,
"content": jsonDecode(album.coverProviderContent),
}),
sortProvider: AlbumSortProvider.fromJson({
"type": album.sortProviderType,
"content": jsonDecode(album.sortProviderContent),
}),
shares: shares.isEmpty
? null
: shares
.map((e) => AlbumShare(
userId: e.userId.toCi(),
displayName: e.displayName,
sharedAt: e.sharedAt.toUtc(),
))
.toList(),
albumFile: albumFile,
savedVersion: album.version,
);
}
static sql.CompleteAlbumCompanion toSql(Album album, int albumFileRowId) {
final providerJson = album.provider.toJson();
final coverProviderJson = album.coverProvider.toJson();
final sortProviderJson = album.sortProvider.toJson();
final dbAlbum = sql.AlbumsCompanion.insert(
file: albumFileRowId,
version: Album.version,
lastUpdated: album.lastUpdated,
name: album.name,
providerType: providerJson["type"],
providerContent: jsonEncode(providerJson["content"]),
coverProviderType: coverProviderJson["type"],
coverProviderContent: jsonEncode(coverProviderJson["content"]),
sortProviderType: sortProviderJson["type"],
sortProviderContent: jsonEncode(sortProviderJson["content"]),
);
final dbAlbumShares = album.shares
?.map((s) => sql.AlbumSharesCompanion(
userId: Value(s.userId.toCaseInsensitiveString()),
displayName: Value(s.displayName),
sharedAt: Value(s.sharedAt),
))
.toList();
return sql.CompleteAlbumCompanion(dbAlbum, dbAlbumShares ?? []);
}
}
class SqliteFileConverter {
static File fromSql(String homeDir, sql.CompleteFile f) {
final metadata = f.image?.run((obj) => Metadata(
lastUpdated: obj.lastUpdated,
fileEtag: obj.fileEtag,
imageWidth: obj.width,
imageHeight: obj.height,
exif: obj.exifRaw?.run((e) => Exif.fromJson(jsonDecode(e))),
));
return File(
path: "remote.php/dav/files/$homeDir/${f.accountFile.relativePath}",
contentLength: f.file.contentLength,
contentType: f.file.contentType,
etag: f.file.etag,
lastModified: f.file.lastModified,
isCollection: f.file.isCollection,
usedBytes: f.file.usedBytes,
hasPreview: f.file.hasPreview,
fileId: f.file.fileId,
isFavorite: f.accountFile.isFavorite,
ownerId: f.file.ownerId?.toCi(),
trashbinFilename: f.trash?.filename,
trashbinOriginalLocation: f.trash?.originalLocation,
trashbinDeletionTime: f.trash?.deletionTime,
metadata: metadata,
isArchived: f.accountFile.isArchived,
overrideDateTime: f.accountFile.overrideDateTime,
);
}
static sql.CompleteFileCompanion toSql(sql.Account? account, File file) {
final dbFile = sql.FilesCompanion(
server: account == null ? const Value.absent() : Value(account.server),
fileId: Value(file.fileId!),
contentLength: Value(file.contentLength),
contentType: Value(file.contentType),
etag: Value(file.etag),
lastModified: Value(file.lastModified),
isCollection: Value(file.isCollection),
usedBytes: Value(file.usedBytes),
hasPreview: Value(file.hasPreview),
ownerId: Value(file.ownerId!.toCaseInsensitiveString()),
);
final dbAccountFile = sql.AccountFilesCompanion(
account: account == null ? const Value.absent() : Value(account.rowId),
relativePath: Value(file.strippedPathWithEmpty),
isFavorite: Value(file.isFavorite),
isArchived: Value(file.isArchived),
overrideDateTime: Value(file.overrideDateTime),
);
final dbImage = file.metadata?.run((m) => sql.ImagesCompanion.insert(
lastUpdated: m.lastUpdated,
fileEtag: Value(m.fileEtag),
width: Value(m.imageWidth),
height: Value(m.imageHeight),
exifRaw: Value(m.exif?.toJson().run((j) => jsonEncode(j))),
dateTimeOriginal: Value(m.exif?.dateTimeOriginal),
));
final dbTrash = file.trashbinDeletionTime == null
? null
: sql.TrashesCompanion.insert(
filename: file.trashbinFilename!,
originalLocation: file.trashbinOriginalLocation!,
deletionTime: file.trashbinDeletionTime!,
);
return sql.CompleteFileCompanion(dbFile, dbAccountFile, dbImage, dbTrash);
}
}

View file

@ -0,0 +1,538 @@
import 'package:drift/drift.dart';
import 'package:nc_photos/account.dart' as app;
import 'package:nc_photos/entity/file.dart' as app;
import 'package:nc_photos/entity/sqlite_table.dart';
import 'package:nc_photos/entity/sqlite_table_converter.dart';
import 'package:nc_photos/entity/sqlite_table_isolate.dart';
import 'package:nc_photos/future_extension.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
class CompleteFile {
const CompleteFile(this.file, this.accountFile, this.image, this.trash);
final File file;
final AccountFile accountFile;
final Image? image;
final Trash? trash;
}
class CompleteFileCompanion {
const CompleteFileCompanion(
this.file, this.accountFile, this.image, this.trash);
final FilesCompanion file;
final AccountFilesCompanion accountFile;
final ImagesCompanion? image;
final TrashesCompanion? trash;
}
extension CompleteFileListExtension on List<CompleteFile> {
Future<List<app.File>> convertToAppFile(app.Account account) {
return map((f) => {
"homeDir": account.homeDir.toString(),
"completeFile": f,
}).computeAll(_covertSqliteDbFile);
}
}
extension FileListExtension on List<app.File> {
Future<List<CompleteFileCompanion>> convertToFileCompanion(Account? account) {
return map((f) => {
"account": account,
"file": f,
}).computeAll(_convertAppFile);
}
}
class AlbumWithShare {
const AlbumWithShare(this.album, this.share);
final Album album;
final AlbumShare? share;
}
class CompleteAlbumCompanion {
const CompleteAlbumCompanion(this.album, this.albumShares);
final AlbumsCompanion album;
final List<AlbumSharesCompanion> albumShares;
}
class AccountFileRowIds {
const AccountFileRowIds(
this.accountFileRowId, this.accountRowId, this.fileRowId);
final int accountFileRowId;
final int accountRowId;
final int fileRowId;
}
class AccountFileRowIdsWithFileId {
const AccountFileRowIdsWithFileId(
this.accountFileRowId, this.accountRowId, this.fileRowId, this.fileId);
final int accountFileRowId;
final int accountRowId;
final int fileRowId;
final int fileId;
}
extension SqliteDbExtension on SqliteDb {
/// Start a transaction and run [block]
///
/// The [db] argument passed to [block] is identical to this
///
/// Do NOT call this when using [isolate], call [useInIsolate] instead
Future<T> use<T>(Future<T> Function(SqliteDb db) block) async {
return await platform.Lock.synchronized(k.appDbLockId, () async {
return await transaction(() async {
return await block(this);
});
});
}
/// Start an isolate and run [callback] there, with access to the
/// SQLite database
Future<U> isolate<T, U>(T args, ComputeWithDbCallback<T, U> callback) async {
// we need to acquire the lock here as method channel is not supported in
// background isolates
return await platform.Lock.synchronized(k.appDbLockId, () async {
// in unit tests we use an in-memory db, which mean there's no way to
// access it in other isolates
if (platform_k.isUnitTest) {
return await callback(this, args);
} else {
return await computeWithDb(callback, args);
}
});
}
/// Start a transaction and run [block], this version is suitable to be called
/// in [isolate]
///
/// See: [use]
Future<T> useInIsolate<T>(Future<T> Function(SqliteDb db) block) async {
return await transaction(() async {
return await block(this);
});
}
Future<void> insertAccountOf(app.Account account) async {
Server dbServer;
try {
dbServer = await into(servers).insertReturning(
ServersCompanion.insert(
address: account.url,
),
mode: InsertMode.insertOrIgnore,
);
} on StateError catch (_) {
// already exists
final query = select(servers)
..where((t) => t.address.equals(account.url));
dbServer = await query.getSingle();
}
await into(accounts).insert(
AccountsCompanion.insert(
server: dbServer.rowId,
userId: account.username.toCaseInsensitiveString(),
),
mode: InsertMode.insertOrIgnore,
);
}
Future<Account> accountOf(app.Account account) {
final query = select(accounts).join([
innerJoin(servers, servers.rowId.equalsExp(accounts.server),
useColumns: false)
])
..where(servers.address.equals(account.url))
..where(
accounts.userId.equals(account.username.toCaseInsensitiveString()))
..limit(1);
return query.map((r) => r.readTable(accounts)).getSingle();
}
FilesQueryBuilder queryFiles() => FilesQueryBuilder(this);
/// Query File by app File
///
/// Only one of [sqlAccount] and [appAccount] must be passed
Future<File> fileOf(
app.File file, {
Account? sqlAccount,
app.Account? appAccount,
}) {
assert((sqlAccount != null) != (appAccount != null));
final query = queryFiles().run((q) {
q.setQueryMode(FilesQueryMode.file);
if (sqlAccount != null) {
q.setSqlAccount(sqlAccount);
} else {
q.setAppAccount(appAccount!);
}
if (file.fileId != null) {
q.byFileId(file.fileId!);
} else {
q.byRelativePath(file.strippedPathWithEmpty);
}
return q.build()..limit(1);
});
return query.map((r) => r.readTable(files)).getSingle();
}
/// Query AccountFiles, Accounts and Files row ID by app File
///
/// Only one of [sqlAccount] and [appAccount] must be passed
Future<AccountFileRowIds?> accountFileRowIdsOfOrNull(
app.File file, {
Account? sqlAccount,
app.Account? appAccount,
}) {
assert((sqlAccount != null) != (appAccount != null));
final query = queryFiles().run((q) {
q.setQueryMode(FilesQueryMode.expression, expressions: [
accountFiles.rowId,
accountFiles.account,
accountFiles.file,
]);
if (sqlAccount != null) {
q.setSqlAccount(sqlAccount);
} else {
q.setAppAccount(appAccount!);
}
if (file.fileId != null) {
q.byFileId(file.fileId!);
} else {
q.byRelativePath(file.strippedPathWithEmpty);
}
return q.build()..limit(1);
});
return query
.map((r) => AccountFileRowIds(
r.read(accountFiles.rowId)!,
r.read(accountFiles.account)!,
r.read(accountFiles.file)!,
))
.getSingleOrNull();
}
/// See [accountFileRowIdsOfOrNull]
Future<AccountFileRowIds> accountFileRowIdsOf(
app.File file, {
Account? sqlAccount,
app.Account? appAccount,
}) =>
accountFileRowIdsOfOrNull(file,
sqlAccount: sqlAccount, appAccount: appAccount)
.notNull();
/// Query AccountFiles, Accounts and Files row ID by fileIds
///
/// Returned files are NOT guaranteed to be sorted as [fileIds]
Future<List<AccountFileRowIdsWithFileId>> accountFileRowIdsByFileIds(
Iterable<int> fileIds, {
Account? sqlAccount,
app.Account? appAccount,
}) {
assert((sqlAccount != null) != (appAccount != null));
final query = queryFiles().run((q) {
q.setQueryMode(FilesQueryMode.expression, expressions: [
accountFiles.rowId,
accountFiles.account,
accountFiles.file,
files.fileId,
]);
if (sqlAccount != null) {
q.setSqlAccount(sqlAccount);
} else {
q.setAppAccount(appAccount!);
}
q.byFileIds(fileIds);
return q.build();
});
return query
.map((r) => AccountFileRowIdsWithFileId(
r.read(accountFiles.rowId)!,
r.read(accountFiles.account)!,
r.read(accountFiles.file)!,
r.read(files.fileId)!,
))
.get();
}
/// Query CompleteFile by fileId
///
/// Returned files are NOT guaranteed to be sorted as [fileIds]
Future<List<CompleteFile>> completeFilesByFileIds(
Iterable<int> fileIds, {
Account? sqlAccount,
app.Account? appAccount,
}) {
assert((sqlAccount != null) != (appAccount != null));
final query = queryFiles().run((q) {
q.setQueryMode(FilesQueryMode.completeFile);
if (sqlAccount != null) {
q.setSqlAccount(sqlAccount);
} else {
q.setAppAccount(appAccount!);
}
q.byFileIds(fileIds);
return q.build();
});
return query
.map((r) => CompleteFile(
r.readTable(files),
r.readTable(accountFiles),
r.readTableOrNull(images),
r.readTableOrNull(trashes),
))
.get();
}
Future<List<CompleteFile>> completeFilesByDirRowId(
int dirRowId, {
Account? sqlAccount,
app.Account? appAccount,
}) {
assert((sqlAccount != null) != (appAccount != null));
final query = queryFiles().run((q) {
q.setQueryMode(FilesQueryMode.completeFile);
if (sqlAccount != null) {
q.setSqlAccount(sqlAccount);
} else {
q.setAppAccount(appAccount!);
}
q.byDirRowId(dirRowId);
return q.build();
});
return query
.map((r) => CompleteFile(
r.readTable(files),
r.readTable(accountFiles),
r.readTableOrNull(images),
r.readTableOrNull(trashes),
))
.get();
}
/// Query CompleteFile by favorite
Future<List<CompleteFile>> completeFilesByFavorite({
Account? sqlAccount,
app.Account? appAccount,
}) {
assert((sqlAccount != null) != (appAccount != null));
final query = queryFiles().run((q) {
q.setQueryMode(FilesQueryMode.completeFile);
if (sqlAccount != null) {
q.setSqlAccount(sqlAccount);
} else {
q.setAppAccount(appAccount!);
}
q.byFavorite(true);
return q.build();
});
return query
.map((r) => CompleteFile(
r.readTable(files),
r.readTable(accountFiles),
r.readTableOrNull(images),
r.readTableOrNull(trashes),
))
.get();
}
}
enum FilesQueryMode {
file,
completeFile,
expression,
}
/// Build a Files table query
///
/// If you call more than one by* methods, the condition will be added up
/// instead of replaced. No validations will be made to make sure the resulting
/// conditions make sense
class FilesQueryBuilder {
FilesQueryBuilder(this.db);
/// Set the query mode
///
/// If [mode] == FilesQueryMode.expression, [expressions] must be defined and
/// not empty
void setQueryMode(
FilesQueryMode mode, {
Iterable<Expression>? expressions,
}) {
assert(
(mode == FilesQueryMode.expression) != (expressions?.isEmpty != false));
_queryMode = mode;
_selectExpressions = expressions;
}
void setSqlAccount(Account account) {
assert(_appAccount == null);
_sqlAccount = account;
}
void setAppAccount(app.Account account) {
assert(_sqlAccount == null);
_appAccount = account;
}
void setAccountless() {
assert(_sqlAccount == null && _appAccount == null);
_isAccountless = true;
}
void byRowId(int rowId) {
_byRowId = rowId;
}
void byFileId(int fileId) {
_byFileId = fileId;
}
void byFileIds(Iterable<int> fileIds) {
_byFileIds = fileIds;
}
void byRelativePath(String path) {
_byRelativePath = path;
}
void byRelativePathPattern(String pattern) {
_byRelativePathPattern = pattern;
}
void byMimePattern(String pattern) {
(_byMimePatterns ??= []).add(pattern);
}
void byFavorite(bool favorite) {
_byFavorite = favorite;
}
void byDirRowId(int dirRowId) {
_byDirRowId = dirRowId;
}
void byServerRowId(int serverRowId) {
_byServerRowId = serverRowId;
}
JoinedSelectStatement build() {
if (_sqlAccount == null && _appAccount == null && !_isAccountless) {
throw StateError("Invalid query: missing account");
}
final dynamic select = _queryMode == FilesQueryMode.expression
? db.selectOnly(db.files)
: db.select(db.files);
final query = select.join([
innerJoin(db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId),
useColumns: _queryMode == FilesQueryMode.completeFile),
if (_appAccount != null) ...[
innerJoin(
db.accounts, db.accounts.rowId.equalsExp(db.accountFiles.account),
useColumns: false),
innerJoin(db.servers, db.servers.rowId.equalsExp(db.accounts.server),
useColumns: false),
],
if (_byDirRowId != null)
innerJoin(db.dirFiles, db.dirFiles.child.equalsExp(db.files.rowId),
useColumns: false),
if (_queryMode == FilesQueryMode.completeFile) ...[
leftOuterJoin(
db.images, db.images.accountFile.equalsExp(db.accountFiles.rowId)),
leftOuterJoin(db.trashes, db.trashes.file.equalsExp(db.files.rowId)),
],
]) as JoinedSelectStatement;
if (_queryMode == FilesQueryMode.expression) {
query.addColumns(_selectExpressions!);
}
if (_sqlAccount != null) {
query.where(db.accountFiles.account.equals(_sqlAccount!.rowId));
} else if (_appAccount != null) {
query
..where(db.servers.address.equals(_appAccount!.url))
..where(db.accounts.userId
.equals(_appAccount!.username.toCaseInsensitiveString()));
}
if (_byRowId != null) {
query.where(db.files.rowId.equals(_byRowId));
}
if (_byFileId != null) {
query.where(db.files.fileId.equals(_byFileId));
}
if (_byFileIds != null) {
query.where(db.files.fileId.isIn(_byFileIds!));
}
if (_byRelativePath != null) {
query.where(db.accountFiles.relativePath.equals(_byRelativePath));
}
if (_byRelativePathPattern != null) {
query.where(db.accountFiles.relativePath.like(_byRelativePathPattern!));
}
if (_byMimePatterns?.isNotEmpty == true) {
final expression = _byMimePatterns!.sublist(1).fold<Expression<bool?>>(
db.files.contentType.like(_byMimePatterns![0]),
(previousValue, element) =>
previousValue | db.files.contentType.like(element));
query.where(expression);
}
if (_byFavorite != null) {
if (_byFavorite!) {
query.where(db.accountFiles.isFavorite.equals(true));
} else {
// null are treated as false
query.where(db.accountFiles.isFavorite.equals(true).not());
}
}
if (_byDirRowId != null) {
query.where(db.dirFiles.dir.equals(_byDirRowId));
}
if (_byServerRowId != null) {
query.where(db.files.server.equals(_byServerRowId));
}
return query;
}
final SqliteDb db;
FilesQueryMode _queryMode = FilesQueryMode.file;
Iterable<Expression>? _selectExpressions;
Account? _sqlAccount;
app.Account? _appAccount;
bool _isAccountless = false;
int? _byRowId;
int? _byFileId;
Iterable<int>? _byFileIds;
String? _byRelativePath;
String? _byRelativePathPattern;
List<String>? _byMimePatterns;
bool? _byFavorite;
int? _byDirRowId;
int? _byServerRowId;
}
app.File _covertSqliteDbFile(Map map) {
final homeDir = map["homeDir"] as String;
final file = map["completeFile"] as CompleteFile;
return SqliteFileConverter.fromSql(homeDir, file);
}
CompleteFileCompanion _convertAppFile(Map map) {
final account = map["account"] as Account?;
final file = map["file"] as app.File;
return SqliteFileConverter.toSql(account, file);
}

View file

@ -0,0 +1,87 @@
import 'dart:isolate';
import 'package:drift/drift.dart';
import 'package:drift/isolate.dart';
import 'package:flutter/foundation.dart';
import 'package:nc_photos/entity/sqlite_table.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
typedef ComputeWithDbCallback<T, U> = Future<U> Function(
SqliteDb db, T message);
/// Create a drift db running in an isolate
///
/// This is only expected to be used in the main isolate
Future<SqliteDb> createDb() async {
// see: https://drift.simonbinder.eu/docs/advanced-features/isolates/
final driftIsolate = await _createDriftIsolate();
final connection = await driftIsolate.connect();
return SqliteDb.connect(connection);
}
Future<U> computeWithDb<T, U>(
ComputeWithDbCallback<T, U> callback, T args) async {
return await compute(
_computeWithDbImpl<T, U>,
_ComputeWithDbMessage(
await platform.getSqliteConnectionArgs(), callback, args),
);
}
class _IsolateStartRequest {
const _IsolateStartRequest(this.sendDriftIsolate, this.platformArgs);
final SendPort sendDriftIsolate;
final Map<String, dynamic> platformArgs;
}
class _ComputeWithDbMessage<T, U> {
const _ComputeWithDbMessage(
this.sqliteConnectionArgs, this.callback, this.args);
final Map<String, dynamic> sqliteConnectionArgs;
final ComputeWithDbCallback<T, U> callback;
final T args;
}
Future<DriftIsolate> _createDriftIsolate() async {
final args = await platform.getSqliteConnectionArgs();
final receivePort = ReceivePort();
await Isolate.spawn(
_startBackground,
_IsolateStartRequest(receivePort.sendPort, args),
);
// _startBackground will send the DriftIsolate to this ReceivePort
return await receivePort.first as DriftIsolate;
}
void _startBackground(_IsolateStartRequest request) {
// this is the entry point from the background isolate! Let's create
// the database from the path we received
final executor = platform.openSqliteConnectionWithArgs(request.platformArgs);
// we're using DriftIsolate.inCurrent here as this method already runs on a
// background isolate. If we used DriftIsolate.spawn, a third isolate would be
// started which is not what we want!
final driftIsolate = DriftIsolate.inCurrent(
() => DatabaseConnection.fromExecutor(executor),
// this breaks background service!
serialize: false,
);
// inform the starting isolate about this, so that it can call .connect()
request.sendDriftIsolate.send(driftIsolate);
}
Future<U> _computeWithDbImpl<T, U>(_ComputeWithDbMessage<T, U> message) async {
// we don't use driftIsolate because opening a DB normally is found to perform
// better
final sqliteDb = SqliteDb(
executor:
platform.openSqliteConnectionWithArgs(message.sqliteConnectionArgs),
);
try {
return await message.callback(sqliteDb, message.args);
} finally {
await sqliteDb.close();
}
}

33
app/lib/future_util.dart Normal file
View file

@ -0,0 +1,33 @@
import 'dart:async';
import 'package:nc_photos/iterable_extension.dart';
Future<List<T>> waitOr<T>(
Iterable<Future<T>> futures,
T Function(Object error, StackTrace? stackTrace) onError,
) async {
final completer = Completer<List<T>>();
final results = List<T?>.filled(futures.length, null);
var remaining = results.length;
if (remaining == 0) {
return Future.value(const []);
}
void onResult() {
if (--remaining <= 0) {
// finished
completer.complete(results.cast<T>());
}
}
for (final p in futures.withIndex()) {
p.item2.then((value) {
results[p.item1] = value;
onResult();
}).onError((error, stackTrace) {
results[p.item1] = onError(error!, stackTrace);
onResult();
});
}
return completer.future;
}

View file

@ -67,8 +67,13 @@ extension IterableExtension<T> on Iterable<T> {
}
Future<List<U>> computeAll<U>(ComputeCallback<T, U> callback) async {
return await compute(
_computeAllImpl<T, U>, _ComputeAllMessage(callback, asList()));
final list = asList();
if (list.isEmpty) {
return [];
} else {
return await compute(
_computeAllImpl<T, U>, _ComputeAllMessage(callback, list));
}
}
/// Return a list containing elements in this iterable

View file

@ -9,7 +9,7 @@ import 'package:tuple/tuple.dart';
/// The first returned list contains items exist in [b] but not [a], the second
/// returned list contains items exist in [a] but not [b]
Tuple2<List<T>, List<T>> diffWith<T>(
List<T> a, List<T> b, int Function(T a, T b) comparator) {
Iterable<T> a, Iterable<T> b, int Function(T a, T b) comparator) {
final aIt = a.iterator, bIt = b.iterator;
final aMissing = <T>[], bMissing = <T>[];
while (true) {
@ -31,7 +31,8 @@ Tuple2<List<T>, List<T>> diffWith<T>(
}
}
Tuple2<List<T>, List<T>> diff<T extends Comparable>(List<T> a, List<T> b) =>
Tuple2<List<T>, List<T>> diff<T extends Comparable>(
Iterable<T> a, Iterable<T> b) =>
diffWith(a, b, Comparable.compare);
Tuple2<List<T>, List<T>> _diffUntilEqual<T>(

View file

@ -9,7 +9,7 @@ import 'package:nc_photos/widget/my_app.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await app_init.initAppLaunch();
await app_init.init(app_init.InitIsolateType.main);
if (platform_k.isMobile) {
// reset orientation override just in case, see #59

View file

@ -4,9 +4,8 @@ import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/pref.dart';
@ -14,7 +13,9 @@ import 'package:nc_photos/use_case/update_missing_metadata.dart';
/// Task to update metadata for missing files
class MetadataTask {
MetadataTask(this.account, this.pref);
MetadataTask(this._c, this.account, this.pref) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo);
@override
toString() {
@ -28,12 +29,11 @@ class MetadataTask {
final shareFolder =
File(path: file_util.unstripPath(account, pref.getShareFolderOr()));
bool hasScanShareFolder = false;
final fileRepo = FileRepo(FileCachedDataSource(AppDb()));
for (final r in account.roots) {
final dir = File(path: file_util.unstripPath(account, r));
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
final op = UpdateMissingMetadata(
fileRepo, const _UpdateMissingMetadataConfigProvider());
_c.fileRepo, const _UpdateMissingMetadataConfigProvider());
await for (final _ in op(account, dir)) {
if (!Pref().isEnableExifOr()) {
_log.info("[call] EXIF disabled, task ending immaturely");
@ -44,7 +44,7 @@ class MetadataTask {
}
if (!hasScanShareFolder) {
final op = UpdateMissingMetadata(
fileRepo, const _UpdateMissingMetadataConfigProvider());
_c.fileRepo, const _UpdateMissingMetadataConfigProvider());
await for (final _ in op(
account,
shareFolder,
@ -65,6 +65,8 @@ class MetadataTask {
}
}
final DiContainer _c;
final Account account;
final AccountPref pref;

View file

@ -1,4 +1,31 @@
import 'package:idb_sqflite/idb_sqflite.dart';
import 'package:sqflite/sqflite.dart';
import 'dart:io' as dart;
IdbFactory getDbFactory() => getIdbFactorySqflite(databaseFactory);
import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as path_lib;
import 'package:path_provider/path_provider.dart';
Future<Map<String, dynamic>> getSqliteConnectionArgs() async {
// put the database file, called db.sqlite here, into the documents folder
// for your app.
final dbFolder = await getApplicationDocumentsDirectory();
return {
"path": path_lib.join(dbFolder.path, "db.sqlite"),
};
}
QueryExecutor openSqliteConnectionWithArgs(Map<String, dynamic> args) {
final file = dart.File(args["path"]);
return NativeDatabase(
file,
// logStatements: true,
);
}
QueryExecutor openSqliteConnection() {
// the LazyDatabase util lets us find the right location for the file async.
return LazyDatabase(() async {
final args = await getSqliteConnectionArgs();
return openSqliteConnectionWithArgs(args);
});
}

View file

@ -8,3 +8,4 @@ final isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
final isAndroid = !kIsWeb && Platform.isAndroid;
final isDesktop =
!kIsWeb && (Platform.isLinux || Platform.isMacOS || Platform.isWindows);
final isUnitTest = !kIsWeb && Platform.environment.containsKey("FLUTTER_TEST");

View file

@ -3,13 +3,13 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_background_service/flutter_background_service.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_gen/gen_l10n/app_localizations_en.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_init.dart' as app_init;
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/event/native_event.dart';
@ -69,7 +69,7 @@ class _Service {
final service = FlutterBackgroundService();
service.setForegroundMode(true);
await app_init.initAppLaunch();
await app_init.init(app_init.InitIsolateType.service);
await _L10n().init();
_log.info("[call] Service started");
@ -87,6 +87,7 @@ class _Service {
}
onCancelSubscription.cancel();
onDataSubscription.cancel();
await KiwiContainer().resolve<DiContainer>().sqliteDb.close();
service.stopBackgroundService();
_log.info("[call] Service stopped");
}
@ -235,12 +236,12 @@ class _MetadataTask {
final shareFolder = File(
path: file_util.unstripPath(account, accountPref.getShareFolderOr()));
bool hasScanShareFolder = false;
final fileRepo = FileRepo(FileCachedDataSource(AppDb()));
final c = KiwiContainer().resolve<DiContainer>();
for (final r in account.roots) {
final dir = File(path: file_util.unstripPath(account, r));
hasScanShareFolder |= file_util.isOrUnderDir(shareFolder, dir);
final updater = UpdateMissingMetadata(
fileRepo, const _UpdateMissingMetadataConfigProvider());
c.fileRepo, const _UpdateMissingMetadataConfigProvider());
void onServiceStop() {
_log.info("[_updateMetadata] Stopping task: user canceled");
updater.stop();
@ -263,7 +264,7 @@ class _MetadataTask {
}
if (!hasScanShareFolder) {
final shareUpdater = UpdateMissingMetadata(
fileRepo, const _UpdateMissingMetadataConfigProvider());
c.fileRepo, const _UpdateMissingMetadataConfigProvider());
void onServiceStop() {
_log.info("[_updateMetadata] Stopping task: user canceled");
shareUpdater.stop();

View file

@ -6,12 +6,10 @@ import 'package:flutter/services.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/local_file.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/share/data_source.dart';
@ -179,9 +177,9 @@ class ShareHandler {
clearSelection?.call();
isSelectionCleared = true;
final fileRepo = FileRepo(FileCachedDataSource(AppDb()));
final path = await _createDir(fileRepo, account, result.albumName);
await _copyFilesToDir(fileRepo, account, files, path);
final c = KiwiContainer().resolve<DiContainer>();
final path = await _createDir(c.fileRepo, account, result.albumName);
await _copyFilesToDir(c.fileRepo, account, files, path);
controller?.close();
return _shareFileAsLink(
account,

View file

@ -17,12 +17,12 @@ import 'package:nc_photos/use_case/update_album_with_actual_items.dart';
class AddToAlbum {
AddToAlbum(this._c)
: assert(require(_c)),
assert(ListShare.require(_c));
assert(ListShare.require(_c)),
assert(PreProcessAlbum.require(_c));
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.albumRepo) &&
DiContainer.has(c, DiType.shareRepo) &&
DiContainer.has(c, DiType.appDb);
DiContainer.has(c, DiType.shareRepo);
/// Add a list of AlbumItems to [album]
Future<Album> call(
@ -30,7 +30,7 @@ class AddToAlbum {
_log.info("[call] Add ${items.length} items to album '${album.name}'");
assert(album.provider is AlbumStaticProvider);
// resync is needed to work out album cover and latest item
final oldItems = await PreProcessAlbum(_c.appDb)(account, album);
final oldItems = await PreProcessAlbum(_c)(account, album);
final itemSet = oldItems
.map((e) => OverrideComparator<AlbumItem>(
e, _isItemFileEqual, _getItemHashCode))

View file

@ -1,12 +1,12 @@
import 'package:drift/drift.dart' as sql;
import 'package:event_bus/event_bus.dart';
import 'package:idb_shim/idb_client.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/event/event.dart';
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/list_util.dart' as list_util;
@ -17,7 +17,7 @@ class CacheFavorite {
: assert(require(_c)),
assert(ListFavoriteOffline.require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb);
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
/// Cache favorites
Future<void> call(
@ -34,39 +34,45 @@ class CacheFavorite {
final newFavorites = result.item1;
final removedFavorites =
result.item2.map((f) => f.copyWith(isFavorite: false)).toList();
if (newFavorites.isEmpty && removedFavorites.isEmpty) {
final newFileIds = newFavorites.map((f) => f.fileId!).toList();
final removedFileIds = removedFavorites.map((f) => f.fileId!).toList();
if (newFileIds.isEmpty && removedFileIds.isEmpty) {
return;
}
await _c.appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite),
(transaction) async {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
await Future.wait(newFavorites.map((f) async {
_log.info("[call] New favorite: ${f.path}");
await _c.sqliteDb.use((db) async {
final rowIds = await db.accountFileRowIdsByFileIds(
newFileIds + removedFileIds,
appAccount: account,
);
final rowIdsMap =
Map.fromEntries(rowIds.map((e) => MapEntry(e.fileId, e)));
await db.batch((batch) {
for (final id in newFileIds) {
try {
await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(),
AppDbFile2Entry.toPrimaryKeyForFile(account, f));
batch.update(
db.accountFiles,
const sql.AccountFilesCompanion(isFavorite: sql.Value(true)),
where: (sql.$AccountFilesTable t) =>
t.rowId.equals(rowIdsMap[id]!.accountFileRowId),
);
} catch (e, stackTrace) {
_log.shout(
"[call] Failed while writing new favorite to AppDb: ${logFilename(f.path)}",
e,
stackTrace);
_log.shout("[call] File not found in DB: $id", e, stackTrace);
}
}));
await Future.wait(removedFavorites.map((f) async {
_log.info("[call] Remove favorite: ${f.path}");
}
for (final id in removedFileIds) {
try {
await fileStore.put(AppDbFile2Entry.fromFile(account, f).toJson(),
AppDbFile2Entry.toPrimaryKeyForFile(account, f));
batch.update(
db.accountFiles,
const sql.AccountFilesCompanion(isFavorite: sql.Value(null)),
where: (sql.$AccountFilesTable t) =>
t.rowId.equals(rowIdsMap[id]!.accountFileRowId),
);
} catch (e, stackTrace) {
_log.shout(
"[call] Failed while writing removed favorite to AppDb: ${logFilename(f.path)}",
e,
stackTrace);
_log.shout("[call] File not found in DB: $id", e, stackTrace);
}
}));
},
);
}
});
});
KiwiContainer()
.resolve<EventBus>()

View file

@ -1,211 +0,0 @@
import 'package:collection/collection.dart';
import 'package:idb_shim/idb_client.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:path/path.dart' as path_lib;
/// Compatibility helper for v37
class CompatV37 {
static Future<void> setAppDbMigrationFlag(AppDb appDb) async {
_log.info("[setAppDbMigrationFlag] Set db flag");
try {
await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
await metaStore
.put(const AppDbMetaEntryCompatV37(false).toEntry().toJson());
await transaction.completed;
},
);
} catch (e, stackTrace) {
_log.shout(
"[setAppDbMigrationFlag] Failed while setting db flag, drop db instead",
e,
stackTrace);
await appDb.delete();
}
}
static Future<bool> isAppDbNeedMigration(AppDb appDb) async {
final dbItem = await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadOnly),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
return await metaStore.getObject(AppDbMetaEntryCompatV37.key) as Map?;
},
);
if (dbItem == null) {
return false;
}
try {
final dbEntry = AppDbMetaEntry.fromJson(dbItem.cast<String, dynamic>());
final compatV37 = AppDbMetaEntryCompatV37.fromJson(dbEntry.obj);
return !compatV37.isMigrated;
} catch (e, stackTrace) {
_log.shout("[isAppDbNeedMigration] Failed", e, stackTrace);
return true;
}
}
static Future<void> migrateAppDb(AppDb appDb) async {
_log.info("[migrateAppDb] Migrate AppDb");
try {
await appDb.use(
(db) => db.transaction(
[AppDb.file2StoreName, AppDb.dirStoreName, AppDb.metaStoreName],
idbModeReadWrite),
(transaction) async {
final noMediaFiles = <_NoMediaFile>[];
try {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
final dirStore = transaction.objectStore(AppDb.dirStoreName);
// scan the db to see which dirs contain a no media marker
await for (final c in fileStore.openCursor()) {
final item = c.value as Map;
final strippedPath = item["strippedPath"] as String;
if (file_util.isNoMediaMarkerPath(strippedPath)) {
noMediaFiles.add(_NoMediaFile(
item["server"],
item["userId"],
path_lib
.dirname(item["strippedPath"])
.run((p) => p == "." ? "" : p),
item["file"]["fileId"],
));
}
c.next();
}
// sort to make sure parent dirs are always in front of sub dirs
noMediaFiles
.sort((a, b) => a.strippedDirPath.compareTo(b.strippedDirPath));
_log.info(
"[migrateAppDb] nomedia dirs: ${noMediaFiles.toReadableString()}");
if (noMediaFiles.isNotEmpty) {
await _migrateAppDbFileStore(appDb, noMediaFiles,
fileStore: fileStore);
await _migrateAppDbDirStore(appDb, noMediaFiles,
dirStore: dirStore);
}
final metaStore = transaction.objectStore(AppDb.metaStoreName);
await metaStore
.put(const AppDbMetaEntryCompatV37(true).toEntry().toJson());
} catch (_) {
transaction.abort();
rethrow;
}
},
);
} catch (e, stackTrace) {
_log.shout("[migrateAppDb] Failed while migrating, drop db instead", e,
stackTrace);
await appDb.delete();
rethrow;
}
}
/// Remove files under no media dirs
static Future<void> _migrateAppDbFileStore(
AppDb appDb,
List<_NoMediaFile> noMediaFiles, {
required ObjectStore fileStore,
}) async {
await for (final c in fileStore.openCursor()) {
final item = c.value as Map;
final under = noMediaFiles.firstWhereOrNull((e) {
if (e.server != item["server"] || e.userId != item["userId"]) {
return false;
}
final prefix = e.strippedDirPath.isEmpty ? "" : "${e.strippedDirPath}/";
final itemDir = path_lib
.dirname(item["strippedPath"])
.run((p) => p == "." ? "" : p);
// check isNotEmpty to prevent user root being removed when the
// marker is placed in root
return item["strippedPath"].isNotEmpty &&
item["strippedPath"].startsWith(prefix) &&
// keep no media marker in top-most dir
!(itemDir == e.strippedDirPath &&
file_util.isNoMediaMarkerPath(item["strippedPath"]));
});
if (under != null) {
_log.fine("[_migrateAppDbFileStore] Remove db entry: ${c.primaryKey}");
await c.delete();
}
c.next();
}
}
/// Remove dirs under no media dirs
static Future<void> _migrateAppDbDirStore(
AppDb appDb,
List<_NoMediaFile> noMediaFiles, {
required ObjectStore dirStore,
}) async {
await for (final c in dirStore.openCursor()) {
final item = c.value as Map;
final under = noMediaFiles.firstWhereOrNull((e) {
if (e.server != item["server"] || e.userId != item["userId"]) {
return false;
}
final prefix = e.strippedDirPath.isEmpty ? "" : "${e.strippedDirPath}/";
return item["strippedPath"].startsWith(prefix) ||
e.strippedDirPath == item["strippedPath"];
});
if (under != null) {
if (under.strippedDirPath == item["strippedPath"]) {
// this dir contains the no media marker
// remove all children, keep only the marker
final newChildren = (item["children"] as List)
.where((childId) => childId == under.fileId)
.toList();
if (newChildren.isEmpty) {
// ???
_log.severe(
"[_migrateAppDbDirStore] Marker not found in dir: ${item["strippedPath"]}");
// drop this dir
await c.delete();
}
_log.fine(
"[_migrateAppDbDirStore] Migrate db entry: ${c.primaryKey}");
await c.update(Map.of(item).apply((obj) {
obj["children"] = newChildren;
}));
} else {
// this dir is a sub dir
// drop this dir
_log.fine("[_migrateAppDbDirStore] Remove db entry: ${c.primaryKey}");
await c.delete();
}
}
c.next();
}
}
static final _log = Logger("use_case.compat.v37.CompatV37");
}
class _NoMediaFile {
const _NoMediaFile(
this.server, this.userId, this.strippedDirPath, this.fileId);
@override
toString() => "$runtimeType {"
"server: $server, "
"userId: $userId, "
"strippedDirPath: $strippedDirPath, "
"fileId: $fileId, "
"}";
final String server;
// no need to use CiString as all strings are stored with the same casing in
// db
final String userId;
final String strippedDirPath;
final int fileId;
}

View file

@ -1,74 +0,0 @@
import 'package:idb_shim/idb_client.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/object_extension.dart';
class DbCompatV5 {
static Future<bool> isNeedMigration(AppDb appDb) async {
final dbItem = await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadOnly),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
return await metaStore.getObject(AppDbMetaEntryDbCompatV5.key) as Map?;
},
);
if (dbItem == null) {
return false;
}
try {
final dbEntry = AppDbMetaEntry.fromJson(dbItem.cast<String, dynamic>());
final compatV35 = AppDbMetaEntryDbCompatV5.fromJson(dbEntry.obj);
return !compatV35.isMigrated;
} catch (e, stackTrace) {
_log.shout("[isNeedMigration] Failed", e, stackTrace);
return true;
}
}
static Future<void> migrate(AppDb appDb) async {
_log.info("[migrate] Migrate AppDb");
try {
await appDb.use(
(db) => db.transaction(
[AppDb.file2StoreName, AppDb.metaStoreName], idbModeReadWrite),
(transaction) async {
try {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
await for (final c in fileStore.openCursor()) {
final item = c.value as Map;
// migrate file entry: add bestDateTime
final fileEntry = item.cast<String, dynamic>().run((json) {
final f = File.fromJson(json["file"].cast<String, dynamic>());
return AppDbFile2Entry(
json["server"],
(json["userId"] as String).toCi(),
json["strippedPath"],
f.bestDateTime.millisecondsSinceEpoch,
File.fromJson(json["file"].cast<String, dynamic>()),
);
});
await c.update(fileEntry.toJson());
c.next();
}
final metaStore = transaction.objectStore(AppDb.metaStoreName);
await metaStore
.put(const AppDbMetaEntryDbCompatV5(true).toEntry().toJson());
} catch (_) {
transaction.abort();
rethrow;
}
},
);
} catch (e, stackTrace) {
_log.shout(
"[migrate] Failed while migrating, drop db instead", e, stackTrace);
await appDb.delete();
rethrow;
}
}
static final _log = Logger("use_case.db_compat.v5.DbCompatV5");
}

View file

@ -1,14 +1,14 @@
import 'package:flutter/foundation.dart';
import 'package:idb_shim/idb_client.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/iterable_extension.dart';
class FindFile {
FindFile(this._c) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb);
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
/// Find list of files in the DB by [fileIds]
///
@ -19,37 +19,34 @@ class FindFile {
List<int> fileIds, {
void Function(int fileId)? onFileNotFound,
}) async {
final dbItems = await _c.appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
return await Future.wait(fileIds.map((id) =>
fileStore.getObject(AppDbFile2Entry.toPrimaryKey(account, id))));
},
);
final fileMap = await compute(_covertFileMap, dbItems);
final files = <File>[];
for (final id in fileIds) {
final f = fileMap[id];
if (f == null) {
if (onFileNotFound == null) {
throw StateError("File ID not found: $id");
} else {
onFileNotFound(id);
}
} else {
files.add(f);
}
_log.info("[call] fileIds: ${fileIds.toReadableString()}");
final dbFiles = await _c.sqliteDb.use((db) async {
return await db.completeFilesByFileIds(fileIds, appAccount: account);
});
final files = await dbFiles.convertToAppFile(account);
final fileMap = <int, File>{};
for (final f in files) {
fileMap[f.fileId!] = f;
}
return files;
return () sync* {
for (final id in fileIds) {
final f = fileMap[id];
if (f == null) {
if (onFileNotFound == null) {
throw StateError("File ID not found: $id");
} else {
onFileNotFound(id);
}
} else {
yield fileMap[id]!;
}
}
}()
.toList();
}
final DiContainer _c;
}
Map<int, File> _covertFileMap(List<Object?> dbItems) {
return Map.fromEntries(dbItems
.whereType<Map>()
.map((j) => AppDbFile2Entry.fromJson(j.cast<String, dynamic>()).file)
.map((f) => MapEntry(f.fileId!, f)));
static final _log = Logger("use_case.find_file.FindFile");
}

View file

@ -1,4 +1,4 @@
import 'package:logging/logging.dart';
import 'package:collection/collection.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
@ -50,30 +50,22 @@ class ListAlbum {
yield ExceptionEvent(e, stackTrace);
return;
}
final albumFiles =
final List<File?> albumFiles =
ls.where((element) => element.isCollection != true).toList();
// migrate files
for (var i = 0; i < albumFiles.length; ++i) {
var f = albumFiles[i];
var f = albumFiles[i]!;
try {
if (CompatV25.isAlbumFileNeedMigration(f)) {
f = await CompatV25.migrateAlbumFile(_c, account, f);
albumFiles[i] = await CompatV25.migrateAlbumFile(_c, account, f);
}
albumFiles[i] = f;
yield await _c.albumRepo.get(account, f);
} catch (e, stackTrace) {
yield ExceptionEvent(e, stackTrace);
albumFiles[i] = null;
}
}
try {
_c.albumRepo.cleanUp(
account, remote_storage_util.getRemoteAlbumsDir(account), albumFiles);
} catch (e, stacktrace) {
// not important, log and ignore
_log.shout("[_call] Failed while cleanUp", e, stacktrace);
}
yield* _c.albumRepo.getAll(account, albumFiles.whereNotNull().toList());
}
final DiContainer _c;
static final _log = Logger("use_case.list_album.ListAlbum");
}

View file

@ -1,43 +1,19 @@
import 'package:idb_shim/idb_client.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/use_case/find_file.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
class ListFavoriteOffline {
ListFavoriteOffline(this._c)
: assert(require(_c)),
assert(FindFile.require(_c));
ListFavoriteOffline(this._c) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb);
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
/// List all favorites for [account] from the local DB
Future<List<File>> call(Account account) {
final rootDirs = account.roots
.map((r) => File(path: file_util.unstripPath(account, r)))
.toList();
return _c.appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
final fileIsFavoriteIndex =
fileStore.index(AppDbFile2Entry.fileIsFavoriteIndexName);
return await fileIsFavoriteIndex
.openCursor(
key: AppDbFile2Entry.toFileIsFavoriteIndexKey(account, true),
autoAdvance: true,
)
.map((c) => AppDbFile2Entry.fromJson(
(c.value as Map).cast<String, dynamic>()))
.map((e) => e.file)
.where((f) =>
file_util.isSupportedFormat(f) &&
rootDirs.any((r) => file_util.isOrUnderDir(f, r)))
.toList();
},
);
Future<List<File>> call(Account account) async {
final dbFiles = await _c.sqliteDb.use((db) async {
return await db.completeFilesByFavorite(appAccount: account);
});
return await dbFiles.convertToAppFile(account);
}
final DiContainer _c;

View file

@ -1,7 +1,6 @@
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
@ -15,7 +14,9 @@ import 'package:nc_photos/use_case/list_tagged_file.dart';
import 'package:nc_photos/use_case/scan_dir.dart';
class PopulateAlbum {
const PopulateAlbum(this.appDb);
PopulateAlbum(this._c) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.fileRepo);
Future<List<AlbumItem>> call(Account account, Album album) async {
if (album.provider is AlbumStaticProvider) {
@ -40,7 +41,7 @@ class PopulateAlbum {
final provider = album.provider as AlbumDirProvider;
final products = <AlbumItem>[];
for (final d in provider.dirs) {
final stream = ScanDir(FileRepo(FileCachedDataSource(appDb)))(account, d);
final stream = ScanDir(_c.fileRepo)(account, d);
await for (final result in stream) {
if (result is ExceptionEvent) {
_log.shout(
@ -81,7 +82,7 @@ class PopulateAlbum {
final date = DateTime(provider.year, provider.month, provider.day);
final from = date.subtract(const Duration(days: 2));
final to = date.add(const Duration(days: 3));
final files = await FileAppDbDataSource(appDb).listByDate(
final files = await FileSqliteDbDataSource(_c).listByDate(
account, from.millisecondsSinceEpoch, to.millisecondsSinceEpoch);
return files
.where((f) => file_util.isSupportedFormat(f))
@ -93,7 +94,7 @@ class PopulateAlbum {
.toList();
}
final AppDb appDb;
final DiContainer _c;
static final _log = Logger("use_case.populate_album.PopulateAlbum");
}

View file

@ -1,51 +1,38 @@
import 'package:idb_shim/idb_client.dart';
import 'package:collection/collection.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/face.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
class PopulatePerson {
const PopulatePerson(this.appDb);
PopulatePerson(this._c) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
/// Return a list of files of the faces
Future<List<File>> call(Account account, List<Face> faces) async {
return await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async {
final store = transaction.objectStore(AppDb.file2StoreName);
final products = <File>[];
for (final f in faces) {
try {
products.add(await _populateOne(account, f, fileStore: store));
} catch (e, stackTrace) {
_log.severe("[call] Failed populating file of face: ${f.fileId}", e,
stackTrace);
final fileIds = faces.map((f) => f.fileId).toList();
final dbFiles = await _c.sqliteDb.use((db) async {
return await db.completeFilesByFileIds(fileIds, appAccount: account);
});
final files = await dbFiles.convertToAppFile(account);
final fileMap = Map.fromEntries(files.map((f) => MapEntry(f.fileId, f)));
return faces
.map((f) {
final file = fileMap[f.fileId];
if (file == null) {
_log.warning(
"[call] File doesn't exist in DB, removed?: ${f.fileId}");
}
}
return products;
},
);
return file;
})
.whereNotNull()
.toList();
}
Future<File> _populateOne(
Account account,
Face face, {
required ObjectStore fileStore,
}) async {
final dbItem = await fileStore
.getObject(AppDbFile2Entry.toPrimaryKey(account, face.fileId)) as Map?;
if (dbItem == null) {
_log.warning(
"[_populateOne] File doesn't exist in DB, removed?: '${face.fileId}'");
throw CacheNotFoundException();
}
final dbEntry = AppDbFile2Entry.fromJson(dbItem.cast<String, dynamic>());
return dbEntry.file;
}
final DiContainer _c;
final AppDb appDb;
static final _log = Logger("use_case.populate_album.PopulatePerson");
static final _log = Logger("use_case.populate_person.PopulatePerson");
}

View file

@ -1,5 +1,5 @@
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/album/provider.dart';
@ -12,19 +12,24 @@ import 'package:nc_photos/use_case/resync_album.dart';
/// - with AlbumStaticProvider: [ResyncAlbum]
/// - with AlbumDynamicProvider/AlbumSmartProvider: [PopulateAlbum]
class PreProcessAlbum {
const PreProcessAlbum(this.appDb);
PreProcessAlbum(this._c)
: assert(require(_c)),
assert(PopulateAlbum.require(_c)),
assert(ResyncAlbum.require(_c));
static bool require(DiContainer c) => true;
Future<List<AlbumItem>> call(Account account, Album album) {
if (album.provider is AlbumStaticProvider) {
return ResyncAlbum(appDb)(account, album);
return ResyncAlbum(_c)(account, album);
} else if (album.provider is AlbumDynamicProvider ||
album.provider is AlbumSmartProvider) {
return PopulateAlbum(appDb)(account, album);
return PopulateAlbum(_c)(account, album);
} else {
throw ArgumentError(
"Unknown album provider: ${album.provider.runtimeType}");
}
}
final AppDb appDb;
final DiContainer _c;
}

View file

@ -15,10 +15,10 @@ import 'package:nc_photos/use_case/update_album_with_actual_items.dart';
class RemoveFromAlbum {
RemoveFromAlbum(this._c)
: assert(require(_c)),
assert(UnshareFileFromAlbum.require(_c));
assert(UnshareFileFromAlbum.require(_c)),
assert(PreProcessAlbum.require(_c));
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.albumRepo) && DiContainer.has(c, DiType.appDb);
static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo);
/// Remove a list of AlbumItems from [album]
///
@ -91,7 +91,7 @@ class RemoveFromAlbum {
_log.info(
"[_fixAlbumPostRemove] Resync as interesting item is being removed");
// need to update the album properties
final newItemsSynced = await PreProcessAlbum(_c.appDb)(account, newAlbum);
final newItemsSynced = await PreProcessAlbum(_c)(account, newAlbum);
newAlbum = await UpdateAlbumWithActualItems(null)(
account,
newAlbum,

View file

@ -1,17 +1,19 @@
import 'package:flutter/foundation.dart';
import 'package:idb_shim/idb_client.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/use_case/find_file.dart';
/// Resync files inside an album with the file db
class ResyncAlbum {
const ResyncAlbum(this.appDb);
ResyncAlbum(this._c)
: assert(require(_c)),
assert(FindFile.require(_c));
static bool require(DiContainer c) => true;
Future<List<AlbumItem>> call(Account account, Album album) async {
_log.info("[call] Resync album: ${album.name}");
@ -20,22 +22,27 @@ class ResyncAlbum {
"Resync only make sense for static albums: ${album.name}");
}
final items = AlbumStaticProvider.of(album).items;
final dbItems = await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async {
final store = transaction.objectStore(AppDb.file2StoreName);
return await Future.wait(items.whereType<AlbumFileItem>().map((i) =>
store.getObject(
AppDbFile2Entry.toPrimaryKey(account, i.file.fileId!))));
},
final files = await FindFile(_c)(
account,
items.whereType<AlbumFileItem>().map((i) => i.file.fileId!).toList(),
onFileNotFound: (_) {},
);
final fileMap = await compute(_covertFileMap, dbItems);
final fileIt = files.iterator;
var nextFile = fileIt.moveNext() ? fileIt.current : null;
return items.map((i) {
if (i is AlbumFileItem) {
try {
return i.copyWith(
file: fileMap[i.file.fileId]!,
);
if (i.file.fileId! == nextFile?.fileId) {
final newItem = i.copyWith(
file: nextFile,
);
nextFile = fileIt.moveNext() ? fileIt.current : null;
return newItem;
} else {
_log.warning("[call] File not found: ${logFilename(i.file.path)}");
return i;
}
} catch (e, stackTrace) {
_log.shout(
"[call] Failed syncing file in album: ${logFilename(i.file.path)}",
@ -49,14 +56,7 @@ class ResyncAlbum {
}).toList();
}
final AppDb appDb;
final DiContainer _c;
static final _log = Logger("use_case.resync_album.ResyncAlbum");
}
Map<int, File> _covertFileMap(List<Object?> dbItems) {
return Map.fromEntries(dbItems
.whereType<Map>()
.map((j) => AppDbFile2Entry.fromJson(j.cast<String, dynamic>()).file)
.map((f) => MapEntry(f.fileId!, f)));
}

View file

@ -1,50 +1,60 @@
import 'package:idb_shim/idb_client.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/entity/sqlite_table_converter.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/object_extension.dart';
class ScanDirOffline {
ScanDirOffline(this._c) : assert(require(_c));
static bool require(DiContainer c) => DiContainer.has(c, DiType.appDb);
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
/// List all files under a dir recursively from the local DB
Future<Iterable<File>> call(
Future<List<File>> call(
Account account,
File root, {
bool isOnlySupportedFormat = true,
}) async {
final dbItems = await _c.appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadOnly),
(transaction) async {
final store = transaction.objectStore(AppDb.file2StoreName);
final index = store.index(AppDbFile2Entry.strippedPathIndexName);
final range = KeyRange.bound(
AppDbFile2Entry.toStrippedPathIndexLowerKeyForDir(account, root),
AppDbFile2Entry.toStrippedPathIndexUpperKeyForDir(account, root),
);
return await index
.openCursor(range: range, autoAdvance: false)
.map((c) {
final v = c.value as Map;
c.next();
return v;
}).toList();
},
);
final results = await dbItems.computeAll(_covertAppDbFile2Entry);
if (isOnlySupportedFormat) {
return results.where((f) => file_util.isSupportedFormat(f));
} else {
return results;
}
return await _c.sqliteDb.isolate({
"account": account,
"root": root,
"isOnlySupportedFormat": isOnlySupportedFormat,
}, (db, Map args) async {
final Account account = args["account"];
final File root = args["root"];
final bool isOnlySupportedFormat = args["isOnlySupportedFormat"];
final dbFiles = await db.useInIsolate((db) async {
final query = db.queryFiles().run((q) {
q
..setQueryMode(sql.FilesQueryMode.completeFile)
..setAppAccount(account);
root.strippedPathWithEmpty.run((p) {
if (p.isNotEmpty) {
q.byRelativePathPattern("$p/%");
}
});
if (isOnlySupportedFormat) {
q
..byMimePattern("image/%")
..byMimePattern("video/%");
}
return q.build();
});
return await query
.map((r) => sql.CompleteFile(
r.readTable(db.files),
r.readTable(db.accountFiles),
r.readTableOrNull(db.images),
r.readTableOrNull(db.trashes),
))
.get();
});
return dbFiles
.map(
(f) => SqliteFileConverter.fromSql(account.homeDir.toString(), f))
.toList();
});
}
final DiContainer _c;
}
File _covertAppDbFile2Entry(Map json) =>
AppDbFile2Entry.fromJson(json.cast<String, dynamic>()).file;

View file

@ -1,4 +1,30 @@
import 'package:idb_shim/idb_browser.dart';
import 'package:idb_shim/idb_shim.dart';
import 'package:drift/drift.dart';
import 'package:drift/wasm.dart';
import 'package:http/http.dart' as http;
import 'package:sqlite3/wasm.dart';
IdbFactory getDbFactory() => idbFactoryBrowser;
Future<Map<String, dynamic>> getSqliteConnectionArgs() async => {};
QueryExecutor openSqliteConnectionWithArgs(Map<String, dynamic> args) =>
openSqliteConnection();
QueryExecutor openSqliteConnection() {
return LazyDatabase(() async {
// Load wasm bundle
final response = await http.get(Uri.parse("sqlite3.wasm"));
// Create a virtual file system backed by IndexedDb with everything in
// `/drift/my_app/` being persisted.
final fs = await IndexedDbFileSystem.open(dbName: "nc-photos");
final sqlite3 = await WasmSqlite3.load(
response.bodyBytes,
SqliteEnvironment(fileSystem: fs),
);
// Then, open a database inside that persisted folder.
return WasmDatabase(
sqlite3: sqlite3,
path: "/drift/nc-photos/app.db",
// logStatements: true,
);
});
}

View file

@ -4,7 +4,6 @@ import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/download_handler.dart';
@ -82,6 +81,15 @@ class _AlbumBrowserState extends State<AlbumBrowser>
SelectableItemStreamListMixin<AlbumBrowser>,
DraggableItemListMixin<AlbumBrowser>,
AlbumBrowserMixin<AlbumBrowser> {
_AlbumBrowserState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
assert(PreProcessAlbum.require(c));
_c = c;
}
static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo);
@override
initState() {
super.initState();
@ -156,11 +164,10 @@ class _AlbumBrowserState extends State<AlbumBrowser>
if (newAlbum.copyWith(lastUpdated: OrNull(_album!.lastUpdated)) !=
_album) {
_log.info("[doneEditMode] Album modified: $newAlbum");
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
setState(() {
_album = newAlbum;
});
UpdateAlbum(albumRepo)(
UpdateAlbum(_c.albumRepo)(
widget.account,
newAlbum,
).catchError((e, stackTrace) {
@ -184,15 +191,14 @@ class _AlbumBrowserState extends State<AlbumBrowser>
}
Future<void> _initAlbum() async {
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
var album = await albumRepo.get(widget.account, widget.album.albumFile!);
var album = await _c.albumRepo.get(widget.account, widget.album.albumFile!);
if (widget.album.shares?.isNotEmpty == true) {
try {
final file = await LsSingleFile(KiwiContainer().resolve<DiContainer>())(
widget.account, album.albumFile!.path);
final file =
await LsSingleFile(_c)(widget.account, album.albumFile!.path);
if (file.etag != album.albumFile!.etag) {
_log.info("[_initAlbum] Album modified in remote, forcing download");
album = await albumRepo.get(widget.account, File(path: file.path));
album = await _c.albumRepo.get(widget.account, File(path: file.path));
}
} catch (e, stackTrace) {
_log.warning("[_initAlbum] Failed while syncing remote album file", e,
@ -815,7 +821,7 @@ class _AlbumBrowserState extends State<AlbumBrowser>
Future<void> _setAlbum(Album album) async {
assert(album.provider is AlbumStaticProvider);
final items = await PreProcessAlbum(AppDb())(widget.account, album);
final items = await PreProcessAlbum(_c)(widget.account, album);
if (album.albumFile!.isOwned(widget.account.username)) {
album = await _updateAlbumPostResync(album, items);
}
@ -835,8 +841,7 @@ class _AlbumBrowserState extends State<AlbumBrowser>
Future<Album> _updateAlbumPostResync(
Album album, List<AlbumItem> items) async {
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
return await UpdateAlbumWithActualItems(albumRepo)(
return await UpdateAlbumWithActualItems(_c.albumRepo)(
widget.account, album, items);
}
@ -866,6 +871,8 @@ class _AlbumBrowserState extends State<AlbumBrowser>
static List<AlbumItem> _getAlbumItemsOf(Album a) =>
AlbumStaticProvider.of(a).items;
late final DiContainer _c;
Album? _album;
var _sortedItems = <AlbumItem>[];
var _backingFiles = <File>[];

View file

@ -3,12 +3,12 @@ import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/cover_provider.dart';
import 'package:nc_photos/entity/album/data_source.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/notified_action.dart';
import 'package:nc_photos/pref.dart';
@ -197,10 +197,11 @@ mixin AlbumBrowserMixin<T extends StatefulWidget>
Future<void> _onUnsetCoverPressed(Account account, Album album) async {
_log.info("[_onUnsetCoverPressed] Unset album cover for '${album.name}'");
final c = KiwiContainer().resolve<DiContainer>();
try {
await NotifiedAction(
() async {
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
final albumRepo = AlbumRepo(AlbumCachedDataSource(c));
await UpdateAlbum(albumRepo)(
account,
album.copyWith(

View file

@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/list_importable_album.dart';
import 'package:nc_photos/di_container.dart';
@ -54,6 +53,15 @@ class AlbumImporter extends StatefulWidget {
}
class _AlbumImporterState extends State<AlbumImporter> {
_AlbumImporterState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
assert(PreProcessAlbum.require(c));
_c = c;
}
static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo);
@override
initState() {
super.initState();
@ -236,12 +244,11 @@ class _AlbumImporterState extends State<AlbumImporter> {
);
_log.info("[_createAllAlbums] Creating dir album: $album");
final items = await PreProcessAlbum(AppDb())(widget.account, album);
final items = await PreProcessAlbum(_c)(widget.account, album);
album = await UpdateAlbumWithActualItems(null)(
widget.account, album, items);
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
await CreateAlbum(albumRepo)(widget.account, album);
await CreateAlbum(_c.albumRepo)(widget.account, album);
} catch (e, stacktrace) {
_log.shout(
"[_createAllAlbums] Failed creating dir album", e, stacktrace);
@ -260,6 +267,7 @@ class _AlbumImporterState extends State<AlbumImporter> {
.toList();
}
late final DiContainer _c;
late ListImportableAlbumBloc _bloc;
var _backingFiles = <File>[];

View file

@ -2,15 +2,15 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/scan_account_dir.dart';
import 'package:nc_photos/compute_queue.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/language_util.dart' as language_util;
@ -237,11 +237,11 @@ class _ArchiveBrowserState extends State<ArchiveBrowser>
setState(() {
clearSelectedItems();
});
final fileRepo = FileRepo(FileCachedDataSource(AppDb()));
final c = KiwiContainer().resolve<DiContainer>();
final failures = <File>[];
for (final f in selectedFiles) {
try {
await UpdateProperty(fileRepo)
await UpdateProperty(c.fileRepo)
.updateIsArchived(widget.account, f, false);
} catch (e, stacktrace) {
_log.shout(

View file

@ -4,7 +4,6 @@ import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
@ -76,6 +75,15 @@ class _DynamicAlbumBrowserState extends State<DynamicAlbumBrowser>
with
SelectableItemStreamListMixin<DynamicAlbumBrowser>,
AlbumBrowserMixin<DynamicAlbumBrowser> {
_DynamicAlbumBrowserState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
assert(PreProcessAlbum.require(c));
_c = c;
}
static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo);
@override
initState() {
super.initState();
@ -139,11 +147,10 @@ class _DynamicAlbumBrowserState extends State<DynamicAlbumBrowser>
if (newAlbum.copyWith(lastUpdated: OrNull(_album!.lastUpdated)) !=
_album) {
_log.info("[doneEditMode] Album modified: $newAlbum");
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
setState(() {
_album = newAlbum;
});
UpdateAlbum(albumRepo)(
UpdateAlbum(_c.albumRepo)(
widget.account,
newAlbum,
).catchError((e, stackTrace) {
@ -171,7 +178,7 @@ class _DynamicAlbumBrowserState extends State<DynamicAlbumBrowser>
final List<AlbumItem> items;
final Album album;
try {
items = await PreProcessAlbum(AppDb())(widget.account, widget.album);
items = await PreProcessAlbum(_c)(widget.account, widget.album);
album = await _updateAlbumPostPopulate(widget.album, items);
} catch (e, stackTrace) {
_log.severe("[_initAlbum] Failed while PreProcessAlbum", e, stackTrace);
@ -350,9 +357,8 @@ class _DynamicAlbumBrowserState extends State<DynamicAlbumBrowser>
}
_log.info(
"[_onConvertBasicPressed] Converting album '${_album!.name}' to static");
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
try {
await UpdateAlbum(albumRepo)(
await UpdateAlbum(_c.albumRepo)(
widget.account,
_album!.copyWith(
provider: AlbumStaticProvider(
@ -632,11 +638,12 @@ class _DynamicAlbumBrowserState extends State<DynamicAlbumBrowser>
Future<Album> _updateAlbumPostPopulate(
Album album, List<AlbumItem> items) async {
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
return await UpdateAlbumWithActualItems(albumRepo)(
return await UpdateAlbumWithActualItems(_c.albumRepo)(
widget.account, album, items);
}
late final DiContainer _c;
Album? _album;
var _sortedItems = <AlbumItem>[];
var _backingFiles = <File>[];

View file

@ -5,6 +5,7 @@ import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/data_source.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/k.dart' as k;

View file

@ -737,8 +737,9 @@ class _Web {
}
void startMetadataTask(int missingMetadataCount) {
final c = KiwiContainer().resolve<DiContainer>();
MetadataTaskManager().addTask(MetadataTask(
state.widget.account, AccountPref.of(state.widget.account)));
c, state.widget.account, AccountPref.of(state.widget.account)));
_metadataTaskProcessTotalCount = missingMetadataCount;
}

View file

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
@ -37,6 +36,14 @@ class NewAlbumDialog extends StatefulWidget {
}
class _NewAlbumDialogState extends State<NewAlbumDialog> {
_NewAlbumDialogState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
_c = c;
}
static bool require(DiContainer c) => DiContainer.has(c, DiType.albumRepo);
@override
initState() {
super.initState();
@ -139,8 +146,7 @@ class _NewAlbumDialogState extends State<NewAlbumDialog> {
sortProvider: const AlbumTimeSortProvider(isAscending: false),
);
_log.info("[_onConfirmStaticAlbum] Creating static album: $album");
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
final newAlbum = await CreateAlbum(albumRepo)(widget.account, album);
final newAlbum = await CreateAlbum(_c.albumRepo)(widget.account, album);
Navigator.of(context).pop(newAlbum);
} catch (e, stacktrace) {
_log.shout("[_onConfirmStaticAlbum] Failed", e, stacktrace);
@ -173,8 +179,7 @@ class _NewAlbumDialogState extends State<NewAlbumDialog> {
sortProvider: const AlbumTimeSortProvider(isAscending: false),
);
_log.info("[_onConfirmDirAlbum] Creating dir album: $album");
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
final newAlbum = await CreateAlbum(albumRepo)(widget.account, album);
final newAlbum = await CreateAlbum(_c.albumRepo)(widget.account, album);
Navigator.of(context).pop(newAlbum);
} catch (e, stacktrace) {
_log.shout("[_onConfirmDirAlbum] Failed", e, stacktrace);
@ -220,6 +225,8 @@ class _NewAlbumDialogState extends State<NewAlbumDialog> {
}
}
late final DiContainer _c;
final _formKey = GlobalKey<FormState>();
var _provider = _Provider.static;

View file

@ -8,7 +8,6 @@ import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc/list_face.dart';
import 'package:nc_photos/cache_manager_util.dart';
@ -76,6 +75,14 @@ class PersonBrowser extends StatefulWidget {
class _PersonBrowserState extends State<PersonBrowser>
with SelectableItemStreamListMixin<PersonBrowser> {
_PersonBrowserState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
_c = c;
}
static bool require(DiContainer c) => DiContainer.has(c, DiType.sqliteDb);
@override
initState() {
super.initState();
@ -414,7 +421,7 @@ class _PersonBrowserState extends State<PersonBrowser>
}
void _transformItems(List<Face> items) async {
final files = await PopulatePerson(AppDb())(widget.account, items);
final files = await PopulatePerson(_c)(widget.account, items);
_backingFiles = files
.sorted(compareFileDateTimeDescending)
.where((element) =>
@ -442,6 +449,8 @@ class _PersonBrowserState extends State<PersonBrowser>
_bloc.add(ListFaceBlocQuery(widget.account, widget.person));
}
late final DiContainer _c;
final ListFaceBloc _bloc = ListFaceBloc();
List<File>? _backingFiles;

View file

@ -12,9 +12,9 @@ import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/mobile/android/android_info.dart';
import 'package:nc_photos/mobile/platform.dart'
if (dart.library.html) 'package:nc_photos/web/platform.dart' as platform;
import 'package:nc_photos/platform/features.dart' as features;
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/platform/notification.dart';
import 'package:nc_photos/platform/features.dart' as features;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/service.dart';
import 'package:nc_photos/snack_bar_manager.dart';

View file

@ -5,7 +5,6 @@ import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:mutex/mutex.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/async_util.dart' as async_util;
import 'package:nc_photos/bloc/list_sharee.dart';
@ -13,8 +12,6 @@ import 'package:nc_photos/bloc/search_suggestion.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/share/data_source.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/k.dart' as k;
@ -41,6 +38,16 @@ class ShareAlbumDialog extends StatefulWidget {
}
class _ShareAlbumDialogState extends State<ShareAlbumDialog> {
_ShareAlbumDialogState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
_c = c;
}
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.albumRepo) &&
DiContainer.has(c, DiType.shareRepo);
@override
initState() {
super.initState();
@ -227,12 +234,10 @@ class _ShareAlbumDialogState extends State<ShareAlbumDialog> {
}
Future<bool> _createShare(Sharee sharee) async {
final shareRepo = ShareRepo(ShareRemoteDataSource());
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
var hasFailure = false;
try {
_album = await _editMutex.protect(() async {
return await ShareAlbumWithUser(shareRepo, albumRepo)(
return await ShareAlbumWithUser(_c.shareRepo, _c.albumRepo)(
widget.account,
_album,
sharee,
@ -320,6 +325,8 @@ class _ShareAlbumDialogState extends State<ShareAlbumDialog> {
}
}
late final DiContainer _c;
late final _shareeBloc = ListShareeBloc.of(widget.account);
final _suggestionBloc = SearchSuggestionBloc<Sharee>(
itemToKeywords: (item) => [item.shareWith, item.label.toCi()],

View file

@ -13,6 +13,7 @@ import 'package:nc_photos/bloc/list_sharing.dart';
import 'package:nc_photos/cache_manager_util.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/data_source.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/share.dart';

View file

@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/help_utils.dart' as help_utils;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
@ -253,6 +256,10 @@ class _SignInState extends State<SignIn> {
return;
}
// we've got a good account
final c = KiwiContainer().resolve<DiContainer>();
await c.sqliteDb.use((db) async {
await db.insertAccountOf(account!);
});
// only signing in with app password would trigger distinct
final accounts = (Pref().getAccounts3Or([])..add(account)).distinct();
try {

View file

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/api/api_util.dart' as api_util;
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/download_handler.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/item.dart';
@ -60,6 +61,12 @@ class _SmartAlbumBrowserState extends State<SmartAlbumBrowser>
with
SelectableItemStreamListMixin<SmartAlbumBrowser>,
AlbumBrowserMixin<SmartAlbumBrowser> {
_SmartAlbumBrowserState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(PreProcessAlbum.require(c));
_c = c;
}
@override
initState() {
super.initState();
@ -89,7 +96,7 @@ class _SmartAlbumBrowserState extends State<SmartAlbumBrowser>
Future<void> _initAlbum() async {
assert(widget.album.provider is AlbumSmartProvider);
_log.info("[_initAlbum] ${widget.album}");
final items = await PreProcessAlbum(AppDb())(widget.account, widget.album);
final items = await PreProcessAlbum(_c)(widget.account, widget.album);
if (mounted) {
setState(() {
_album = widget.album;
@ -314,6 +321,8 @@ class _SmartAlbumBrowserState extends State<SmartAlbumBrowser>
.toList();
}
late final DiContainer _c;
Album? _album;
var _sortedItems = <AlbumItem>[];
var _backingFiles = <File>[];

View file

@ -1,20 +1,15 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/changelog.dart' as changelog;
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/mobile/android/activity.dart';
import 'package:nc_photos/platform/k.dart' as platform_k;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/use_case/compat/v29.dart';
import 'package:nc_photos/use_case/compat/v37.dart';
import 'package:nc_photos/use_case/db_compat/v5.dart';
import 'package:nc_photos/widget/home.dart';
import 'package:nc_photos/widget/processing_dialog.dart';
import 'package:nc_photos/widget/setup.dart';
@ -47,7 +42,6 @@ class _SplashState extends State<Splash> {
if (_shouldUpgrade()) {
await _handleUpgrade();
}
await _migrateDb();
_initTimedExit();
}
@ -162,10 +156,6 @@ class _SplashState extends State<Splash> {
showUpdateDialog();
await _upgrade29(lastVersion);
}
if (lastVersion < 370) {
showUpdateDialog();
await _upgrade37(lastVersion);
}
if (isShowDialog) {
Navigator.of(context).pop();
}
@ -181,53 +171,6 @@ class _SplashState extends State<Splash> {
}
}
Future<void> _upgrade37(int lastVersion) async {
final c = KiwiContainer().resolve<DiContainer>();
return CompatV37.setAppDbMigrationFlag(c.appDb);
}
Future<void> _migrateDb() async {
bool isShowDialog = false;
void showUpdateDialog() {
if (!isShowDialog) {
isShowDialog = true;
showDialog(
context: context,
builder: (_) => ProcessingDialog(
text: L10n.global().migrateDatabaseProcessingNotification,
),
);
}
}
final c = KiwiContainer().resolve<DiContainer>();
if (await DbCompatV5.isNeedMigration(c.appDb)) {
showUpdateDialog();
try {
await DbCompatV5.migrate(c.appDb);
} catch (_) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().migrateDatabaseFailureNotification),
duration: k.snackBarDurationNormal,
));
}
}
if (await CompatV37.isAppDbNeedMigration(c.appDb)) {
showUpdateDialog();
try {
await CompatV37.migrateAppDb(c.appDb);
} catch (_) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().migrateDatabaseFailureNotification),
duration: k.snackBarDurationNormal,
));
}
}
if (isShowDialog) {
Navigator.of(context).pop();
}
}
String _gatherChangelog(int from) {
if (from < 100) {
from *= 10;

View file

@ -7,7 +7,6 @@ import 'package:intl/intl.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/debug_util.dart';
import 'package:nc_photos/di_container.dart';
@ -17,7 +16,6 @@ import 'package:nc_photos/entity/album/cover_provider.dart';
import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/notified_action.dart';
import 'package:nc_photos/platform/features.dart' as features;
@ -59,6 +57,16 @@ class ViewerDetailPane extends StatefulWidget {
}
class _ViewerDetailPaneState extends State<ViewerDetailPane> {
_ViewerDetailPaneState() {
final c = KiwiContainer().resolve<DiContainer>();
assert(require(c));
_c = c;
}
static bool require(DiContainer c) =>
DiContainer.has(c, DiType.fileRepo) &&
DiContainer.has(c, DiType.albumRepo);
@override
initState() {
super.initState();
@ -380,8 +388,7 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
try {
await NotifiedAction(
() async {
final albumRepo = AlbumRepo(AlbumCachedDataSource(AppDb()));
await UpdateAlbum(albumRepo)(
await UpdateAlbum(_c.albumRepo)(
widget.account,
widget.album!.copyWith(
coverProvider: AlbumManualCoverProvider(
@ -427,8 +434,7 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
try {
await NotifiedAction(
() async {
final fileRepo = FileRepo(FileCachedDataSource(AppDb()));
await UpdateProperty(fileRepo)
await UpdateProperty(_c.fileRepo)
.updateIsArchived(widget.account, widget.file, false);
if (mounted) {
Navigator.of(context).pop();
@ -464,9 +470,8 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
if (value == null || value is! DateTime) {
return;
}
final fileRepo = FileRepo(FileCachedDataSource(AppDb()));
try {
await UpdateProperty(fileRepo)
await UpdateProperty(_c.fileRepo)
.updateOverrideDateTime(widget.account, widget.file, value);
if (mounted) {
setState(() {
@ -520,6 +525,8 @@ class _ViewerDetailPaneState extends State<ViewerDetailPane> {
return false;
}
late final DiContainer _c;
late DateTime _dateTime;
// EXIF data
String? _model;

View file

@ -15,6 +15,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.2.0"
analyzer_plugin:
dependency: transitive
description:
name: analyzer_plugin
url: "https://pub.dartlang.org"
source: hosted
version: "0.10.0"
android_intent_plus:
dependency: "direct main"
description:
@ -108,6 +115,62 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
build:
dependency: transitive
description:
name: build
url: "https://pub.dartlang.org"
source: hosted
version: "2.3.0"
build_config:
dependency: transitive
description:
name: build_config
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
build_daemon:
dependency: transitive
description:
name: build_daemon
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.9"
build_runner:
dependency: "direct dev"
description:
name: build_runner
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.11"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
url: "https://pub.dartlang.org"
source: hosted
version: "7.2.3"
built_collection:
dependency: transitive
description:
name: built_collection
url: "https://pub.dartlang.org"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
url: "https://pub.dartlang.org"
source: hosted
version: "8.3.3"
cached_network_image:
dependency: "direct main"
description:
@ -143,6 +206,20 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.3.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
cli_util:
dependency: transitive
description:
name: cli_util
url: "https://pub.dartlang.org"
source: hosted
version: "0.3.5"
clock:
dependency: transitive
description:
@ -150,6 +227,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.1.0"
code_builder:
dependency: transitive
description:
name: code_builder
url: "https://pub.dartlang.org"
source: hosted
version: "4.1.0"
collection:
dependency: "direct main"
description:
@ -227,6 +311,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.17.2"
dart_style:
dependency: transitive
description:
name: dart_style
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.3"
dbus:
dependency: transitive
description:
@ -299,6 +390,20 @@ packages:
url: "https://gitlab.com/nc-photos/flutter-draggable-scrollbar"
source: git
version: "0.1.0"
drift:
dependency: "direct main"
description:
name: drift
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.1"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.0"
equatable:
dependency: "direct main"
description:
@ -336,6 +441,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.2"
fixnum:
dependency: transitive
description:
name: fixnum
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
flutter:
dependency: "direct main"
description: flutter
@ -467,6 +579,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.0"
graphs:
dependency: transitive
description:
name: graphs
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.0"
hashcodes:
dependency: transitive
description:
@ -502,20 +621,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.1"
idb_shim:
dependency: "direct main"
description:
name: idb_shim
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.1"
idb_sqflite:
dependency: "direct main"
description:
name: idb_sqflite
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
image_size_getter:
dependency: "direct main"
description:
@ -546,6 +651,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.6.4"
json_annotation:
dependency: transitive
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
version: "4.6.0"
kiwi:
dependency: "direct main"
description:
@ -833,6 +945,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.1"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.0"
quiver:
dependency: "direct main"
description:
@ -840,6 +959,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.0"
recase:
dependency: transitive
description:
name: recase
url: "https://pub.dartlang.org"
source: hosted
version: "4.0.0"
rxdart:
dependency: transitive
description:
@ -882,13 +1008,6 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.1.0"
sembast:
dependency: transitive
description:
name: sembast
url: "https://pub.dartlang.org"
source: hosted
version: "3.1.2"
shared_preferences:
dependency: "direct main"
description:
@ -978,6 +1097,13 @@ packages:
description: flutter
source: sdk
version: "0.0.99"
source_gen:
dependency: transitive
description:
name: source_gen
url: "https://pub.dartlang.org"
source: hosted
version: "1.2.2"
source_map_stack_trace:
dependency: transitive
description:
@ -1000,7 +1126,7 @@ packages:
source: hosted
version: "1.9.0"
sqflite:
dependency: "direct main"
dependency: transitive
description:
name: sqflite
url: "https://pub.dartlang.org"
@ -1013,6 +1139,27 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.2.1+1"
sqlite3:
dependency: transitive
description:
name: sqlite3
url: "https://pub.dartlang.org"
source: hosted
version: "1.7.2"
sqlite3_flutter_libs:
dependency: "direct main"
description:
name: sqlite3_flutter_libs
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.8"
sqlparser:
dependency: transitive
description:
name: sqlparser
url: "https://pub.dartlang.org"
source: hosted
version: "0.22.0"
stack_trace:
dependency: transitive
description:
@ -1076,6 +1223,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.4.16"
timing:
dependency: transitive
description:
name: timing
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
tuple:
dependency: "direct main"
description:

View file

@ -45,6 +45,7 @@ dependencies:
git:
url: https://gitlab.com/nc-photos/flutter-draggable-scrollbar
ref: v0.1.0-nc-photos-5
drift: ^1.7.1
equatable: ^2.0.0
event_bus: ^2.0.0
exifdart:
@ -65,9 +66,6 @@ dependencies:
# android/ios only
google_maps_flutter: ^2.1.0
http: ^0.13.1
idb_shim: ^2.0.0
# android/ios only
idb_sqflite: ^1.0.0
image_size_getter:
git:
url: https://gitlab.com/nc-photos/dart_image_size_getter
@ -88,8 +86,7 @@ dependencies:
quiver: ^3.1.0
screen_brightness: ^0.2.1
shared_preferences: ^2.0.8
# android/ios only
sqflite: ^2.0.0
sqlite3_flutter_libs: ^0.5.8
synchronized: ^3.0.0
tuple: ^2.0.0
url_launcher: ^6.0.3
@ -113,6 +110,8 @@ dependency_overrides:
dev_dependencies:
test: any
bloc_test: any
build_runner: ^2.1.11
drift_dev: ^1.7.0
flutter_lints: ^2.0.1
# flutter_test:
# sdk: flutter

View file

@ -2,7 +2,7 @@ import 'package:bloc_test/bloc_test.dart';
import 'package:nc_photos/bloc/list_album_share_outlier.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:test/test.dart';
import '../mock_type.dart';
@ -56,8 +56,9 @@ void _initialState() {
final c = DiContainer(
shareRepo: MockShareRepo(),
shareeRepo: MockShareeRepo(),
appDb: MockAppDb(),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
final bloc = ListAlbumShareOutlierBloc(c);
expect(bloc.state.account, null);
expect(bloc.state.items, const []);
@ -84,11 +85,14 @@ void _testQueryUnsharedAlbumExtraShare(String description) {
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -112,18 +116,22 @@ void _testQueryUnsharedAlbumExtraJsonShare(String description) {
final account = util.buildAccount();
final album = util.AlbumBuilder().build();
final albumFile = album.albumFile!;
final c = DiContainer(
shareRepo: MockShareMemoryRepo([
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
]),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
]),
appDb: MockAppDb(),
);
late final DiContainer c;
blocTest<ListAlbumShareOutlierBloc, ListAlbumShareOutlierBlocState>(
description,
setUp: () async {
c = DiContainer(
shareRepo: MockShareMemoryRepo([
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
]),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
]),
sqliteDb: util.buildTestDb(),
);
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -164,11 +172,14 @@ void _testQuerySharedAlbumMissingShare(String description) {
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -215,11 +226,14 @@ void _testQuerySharedAlbumMissingManagedShareOtherAdded(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -272,12 +286,16 @@ void _testQuerySharedAlbumMissingManagedShareOtherReshared(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDb(obj, user1Account, user1Files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -321,11 +339,14 @@ void _testQuerySharedAlbumMissingUnmanagedShareOtherAdded(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -343,16 +364,20 @@ void _testQuerySharedAlbumMissingJsonShare(String description) {
final account = util.buildAccount();
final album = (util.AlbumBuilder()..addShare("user1")).build();
final albumFile = album.albumFile!;
final c = DiContainer(
shareRepo: MockShareMemoryRepo(),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
]),
appDb: MockAppDb(),
);
late final DiContainer c;
blocTest<ListAlbumShareOutlierBloc, ListAlbumShareOutlierBlocState>(
description,
setUp: () async {
c = DiContainer(
shareRepo: MockShareMemoryRepo(),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
]),
sqliteDb: util.buildTestDb(),
);
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -396,11 +421,14 @@ void _testQuerySharedAlbumExtraShare(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -451,12 +479,16 @@ void _testQuerySharedAlbumExtraShareOtherAdded(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDb(obj, user1Account, user1Files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -513,12 +545,16 @@ void _testQuerySharedAlbumExtraUnmanagedShare(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDb(obj, user1Account, user1Files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -537,20 +573,24 @@ void _testQuerySharedAlbumExtraJsonShare(String description) {
final account = util.buildAccount();
final album = (util.AlbumBuilder()..addShare("user1")).build();
final albumFile = album.albumFile!;
final c = DiContainer(
shareRepo: MockShareMemoryRepo([
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
util.buildShare(id: "1", file: albumFile, shareWith: "user2"),
]),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: MockAppDb(),
);
late final DiContainer c;
blocTest<ListAlbumShareOutlierBloc, ListAlbumShareOutlierBlocState>(
description,
setUp: () async {
c = DiContainer(
shareRepo: MockShareMemoryRepo([
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
util.buildShare(id: "1", file: albumFile, shareWith: "user2"),
]),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
sqliteDb: util.buildTestDb(),
);
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -592,11 +632,14 @@ void _testQuerySharedAlbumNotOwnedMissingShareToOwner(String description) {
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -643,11 +686,14 @@ void _testQuerySharedAlbumNotOwnedMissingManagedShare(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -692,11 +738,14 @@ void _testQuerySharedAlbumNotOwnedMissingUnmanagedShare(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -718,20 +767,24 @@ void _testQuerySharedAlbumNotOwnedMissingJsonShare(String description) {
..addShare("user2"))
.build();
final albumFile = album.albumFile!;
final c = DiContainer(
shareRepo: MockShareMemoryRepo([
util.buildShare(
id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"),
]),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: MockAppDb(),
);
late final DiContainer c;
blocTest<ListAlbumShareOutlierBloc, ListAlbumShareOutlierBlocState>(
description,
setUp: () async {
c = DiContainer(
shareRepo: MockShareMemoryRepo([
util.buildShare(
id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"),
]),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
sqliteDb: util.buildTestDb(),
);
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -772,11 +825,14 @@ void _testQuerySharedAlbumNotOwnedExtraManagedShare(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -822,11 +878,14 @@ void _testQuerySharedAlbumNotOwnedExtraUnmanagedShare(String description) {
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),
@ -851,22 +910,26 @@ void _testQuerySharedAlbumNotOwnedExtraJsonShare(String description) {
final album =
(util.AlbumBuilder(ownerId: "user1")..addShare("admin")).build();
final albumFile = album.albumFile!;
final c = DiContainer(
shareRepo: MockShareMemoryRepo([
util.buildShare(
id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"),
util.buildShare(
id: "1", file: albumFile, uidOwner: "user1", shareWith: "user2"),
]),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
appDb: MockAppDb(),
);
late final DiContainer c;
blocTest<ListAlbumShareOutlierBloc, ListAlbumShareOutlierBlocState>(
description,
setUp: () async {
c = DiContainer(
shareRepo: MockShareMemoryRepo([
util.buildShare(
id: "0", file: albumFile, uidOwner: "user1", shareWith: "admin"),
util.buildShare(
id: "1", file: albumFile, uidOwner: "user1", shareWith: "user2"),
]),
shareeRepo: MockShareeMemoryRepo([
util.buildSharee(shareWith: "user1".toCi()),
util.buildSharee(shareWith: "user2".toCi()),
]),
sqliteDb: util.buildTestDb(),
);
},
tearDown: () => c.sqliteDb.close(),
build: () => ListAlbumShareOutlierBloc(c),
act: (bloc) => bloc.add(ListAlbumShareOutlierBlocQuery(account, album)),
wait: const Duration(milliseconds: 500),

View file

@ -1,8 +1,6 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/bloc/ls_dir.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:path/path.dart' as path_lib;
import 'package:test/test.dart';
import '../mock_type.dart';
@ -134,32 +132,30 @@ void main() {
});
}
class _MockFileRepo extends MockFileRepo {
@override
list(Account account, File root) async {
return [
File(
path: "remote.php/dav/files/admin/test1.jpg",
),
File(
path: "remote.php/dav/files/admin/d1",
isCollection: true,
),
File(
path: "remote.php/dav/files/admin/d1/test2.jpg",
),
File(
path: "remote.php/dav/files/admin/d1/d2-1",
isCollection: true,
),
File(
path: "remote.php/dav/files/admin/d1/d2-2",
isCollection: true,
),
File(
path: "remote.php/dav/files/admin/d1/d2-1/d3",
isCollection: true,
),
].where((element) => path_lib.dirname(element.path) == root.path).toList();
}
class _MockFileRepo extends MockFileMemoryRepo {
_MockFileRepo()
: super([
File(
path: "remote.php/dav/files/admin/test1.jpg",
),
File(
path: "remote.php/dav/files/admin/d1",
isCollection: true,
),
File(
path: "remote.php/dav/files/admin/d1/test2.jpg",
),
File(
path: "remote.php/dav/files/admin/d1/d2-1",
isCollection: true,
),
File(
path: "remote.php/dav/files/admin/d1/d2-2",
isCollection: true,
),
File(
path: "remote.php/dav/files/admin/d1/d2-1/d3",
isCollection: true,
),
]);
}

View file

@ -0,0 +1,335 @@
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/cover_provider.dart';
import 'package:nc_photos/entity/album/data_source.dart';
import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/exception.dart';
import 'package:nc_photos/or_null.dart';
import 'package:test/test.dart';
import '../../test_util.dart' as util;
void main() {
group("AlbumSqliteDbDataSource", () {
group("get", () {
test("normal", _dbGet);
test("n/a", _dbGetNa);
});
group("getAll", () {
test("normal", _dbGetAll);
test("n/a", _dbGetAllNa);
});
group("update", () {
test("existing album", _dbUpdateExisting);
test("new album", _dbUpdateNew);
test("shares", _dbUpdateShares);
test("delete shares", _dbUpdateDeleteShares);
});
});
}
/// Get an album from DB
///
/// Expect: album
Future<void> _dbGet() async {
final account = util.buildAccount();
final albums = [
(util.AlbumBuilder.ofId(albumId: 0)).build(),
(util.AlbumBuilder.ofId(albumId: 1)).build(),
];
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(
c.sqliteDb, account, albums.map((a) => a.albumFile!));
await util.insertAlbums(c.sqliteDb, account, albums);
});
final src = AlbumSqliteDbDataSource(c);
expect(await src.get(account, albums[0].albumFile!), albums[0]);
}
/// Get an album that doesn't exist in DB
///
/// Expect: CacheNotFoundException
Future<void> _dbGetNa() async {
final account = util.buildAccount();
final albums = [
(util.AlbumBuilder.ofId(albumId: 0)).build(),
];
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
});
final src = AlbumSqliteDbDataSource(c);
expect(
() async => await src.get(account, albums[0].albumFile!),
throwsA(const TypeMatcher<CacheNotFoundException>()),
);
}
/// Get multiple albums from DB
///
/// Expect: albums
Future<void> _dbGetAll() async {
final account = util.buildAccount();
final albums = [
(util.AlbumBuilder.ofId(albumId: 0)).build(),
(util.AlbumBuilder.ofId(albumId: 1)).build(),
(util.AlbumBuilder.ofId(albumId: 2)).build(),
];
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(
c.sqliteDb, account, albums.map((a) => a.albumFile!));
await util.insertAlbums(c.sqliteDb, account, albums);
});
final src = AlbumSqliteDbDataSource(c);
expect(
await src
.getAll(account, [albums[0].albumFile!, albums[2].albumFile!]).toList(),
[albums[0], albums[2]],
);
}
/// Get multiple albums that doesn't exists in DB
///
/// Expect: ExceptionEvent with CacheNotFoundException
Future<void> _dbGetAllNa() async {
final account = util.buildAccount();
final albums = [
(util.AlbumBuilder.ofId(albumId: 0)).build(),
(util.AlbumBuilder.ofId(albumId: 1)).build(),
(util.AlbumBuilder.ofId(albumId: 2)).build(),
];
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, [albums[0].albumFile!]);
await util.insertAlbums(c.sqliteDb, account, [albums[0]]);
});
final src = AlbumSqliteDbDataSource(c);
final results = await src
.getAll(account, [albums[0].albumFile!, albums[2].albumFile!]).toList();
expect(results.length, 2);
expect(results[0], albums[0]);
expect(
() => throw results[1],
throwsA(const TypeMatcher<CacheNotFoundException>()),
);
}
/// Update an existing album in DB
///
/// Expect: album updated
Future<void> _dbUpdateExisting() async {
final account = util.buildAccount();
final albums = [
(util.AlbumBuilder.ofId(albumId: 0)).build(),
(util.AlbumBuilder.ofId(albumId: 1)).build(),
];
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg"))
.build();
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(
c.sqliteDb, account, albums.map((a) => a.albumFile!));
await util.insertAlbums(c.sqliteDb, account, albums);
});
final updateAlbum = albums[1].copyWith(
name: "edit",
lastUpdated: OrNull(DateTime.utc(2021, 2, 3, 4, 5, 6)),
provider: AlbumStaticProvider(
items: [
AlbumLabelItem(
addedBy: "admin".toCi(),
addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6),
text: "test",
),
AlbumFileItem(
addedBy: "admin".toCi(),
addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6),
file: files[1],
),
],
),
coverProvider: AlbumManualCoverProvider(coverFile: files[1]),
sortProvider: const AlbumTimeSortProvider(isAscending: true),
);
final src = AlbumSqliteDbDataSource(c);
await src.update(account, updateAlbum);
expect(
await util.listSqliteDbAlbums(c.sqliteDb),
{albums[0], updateAlbum},
);
}
/// Update an album that doesn't exist in DB
///
/// Expect: album inserted
Future<void> _dbUpdateNew() async {
final account = util.buildAccount();
final albums = [
(util.AlbumBuilder.ofId(albumId: 0)).build(),
];
final newAlbum = (util.AlbumBuilder.ofId(albumId: 1)).build();
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account,
[...albums.map((a) => a.albumFile!), newAlbum.albumFile!]);
await util.insertAlbums(c.sqliteDb, account, albums);
});
final src = AlbumSqliteDbDataSource(c);
await src.update(account, newAlbum);
expect(
await util.listSqliteDbAlbums(c.sqliteDb),
{...albums, newAlbum},
);
}
/// Update shares of an album
///
/// Expect: album shares updated
Future<void> _dbUpdateShares() async {
final account = util.buildAccount();
final albums = [
(util.AlbumBuilder.ofId(albumId: 0)).build(),
(util.AlbumBuilder.ofId(albumId: 1)..addShare("user1")).build(),
];
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg"))
.build();
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(
c.sqliteDb, account, albums.map((a) => a.albumFile!));
await util.insertAlbums(c.sqliteDb, account, albums);
});
final updateAlbum = albums[1].copyWith(
name: "edit",
lastUpdated: OrNull(DateTime.utc(2021, 2, 3, 4, 5, 6)),
provider: AlbumStaticProvider(
items: [
AlbumLabelItem(
addedBy: "admin".toCi(),
addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6),
text: "test",
),
AlbumFileItem(
addedBy: "admin".toCi(),
addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6),
file: files[1],
),
],
),
coverProvider: AlbumManualCoverProvider(coverFile: files[1]),
sortProvider: const AlbumTimeSortProvider(isAscending: true),
shares: OrNull([
AlbumShare(
userId: "user1".toCi(),
sharedAt: DateTime.utc(2021, 2, 3, 4, 5, 6),
),
AlbumShare(
userId: "user2".toCi(),
sharedAt: DateTime.utc(2021, 2, 3, 4, 5, 7),
),
]),
);
final src = AlbumSqliteDbDataSource(c);
await src.update(account, updateAlbum);
expect(
await util.listSqliteDbAlbums(c.sqliteDb),
{albums[0], updateAlbum},
);
}
/// Delete shares of an album
///
/// Expect: album shares deleted
Future<void> _dbUpdateDeleteShares() async {
final account = util.buildAccount();
final albums = [
(util.AlbumBuilder.ofId(albumId: 0)).build(),
(util.AlbumBuilder.ofId(albumId: 1)..addShare("user1")).build(),
];
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg"))
.build();
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(
c.sqliteDb, account, albums.map((a) => a.albumFile!));
await util.insertAlbums(c.sqliteDb, account, albums);
});
final updateAlbum = albums[1].copyWith(
name: "edit",
lastUpdated: OrNull(DateTime.utc(2021, 2, 3, 4, 5, 6)),
provider: AlbumStaticProvider(
items: [
AlbumLabelItem(
addedBy: "admin".toCi(),
addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6),
text: "test",
),
AlbumFileItem(
addedBy: "admin".toCi(),
addedAt: DateTime.utc(2021, 2, 3, 4, 5, 6),
file: files[1],
),
],
),
coverProvider: AlbumManualCoverProvider(coverFile: files[1]),
sortProvider: const AlbumTimeSortProvider(isAscending: true),
shares: OrNull(null),
);
final src = AlbumSqliteDbDataSource(c);
await src.update(account, updateAlbum);
expect(
await util.listSqliteDbAlbums(c.sqliteDb),
{albums[0], updateAlbum},
);
}

View file

@ -1,16 +1,15 @@
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/or_null.dart';
import 'package:test/test.dart';
import '../../mock_type.dart';
import '../../test_util.dart' as util;
void main() {
group("FileAppDbDataSource", () {
group("FileSqliteDbDataSource", () {
test("list", _list);
test("listSingle", _listSingle);
group("remove", () {
@ -19,7 +18,12 @@ void main() {
test("dir w/ file", _removeDir);
test("dir w/ sub dir", _removeDirWithSubDir);
});
test("updateProperty", _updateProperty);
group("updateProperty", () {
test("file properties", _updateFileProperty);
test("update metadata", _updateMetadata);
test("add metadata", _updateAddMetadata);
test("delete metadata", _updateDeleteMetadata);
});
});
}
@ -38,12 +42,19 @@ Future<void> _list() async {
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final appDb = await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDbDir(obj, account, files[0], files.slice(1, 3));
await util.fillAppDbDir(obj, account, files[2], [files[3]]);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final src = FileAppDbDataSource(appDb);
final src = FileSqliteDbDataSource(c);
expect(await src.list(account, files[0]), files.slice(0, 3));
expect(await src.list(account, files[2]), files.slice(2, 4));
}
@ -54,69 +65,83 @@ Future<void> _list() async {
Future<void> _listSingle() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()..addDir("admin")).build();
final appDb = await MockAppDb().applyFuture((obj) async {
await util.fillAppDbDir(obj, account, files[0], []);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], const []);
});
final src = FileAppDbDataSource(appDb);
final src = FileSqliteDbDataSource(c);
expect(() async => await src.listSingle(account, files[0]),
throwsUnimplementedError);
}
/// Remove a file
///
/// Expect: entry removed from file2Store
/// Expect: entry removed from Files table
Future<void> _removeFile() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg"))
.build();
final appDb = await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDbDir(obj, account, files[0], [files[1]]);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]);
});
final src = FileAppDbDataSource(appDb);
final src = FileSqliteDbDataSource(c);
await src.remove(account, files[1]);
expect(
(await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e)))
.map((e) => e.file)
.toList(),
[files[0]],
await util.listSqliteDbFiles(c.sqliteDb),
{files[0]},
);
}
/// Remove an empty dir
///
/// Expect: dir entry removed from dirStore;
/// no changes to parent dir entry
/// Expect: entry removed from DirFiles table
Future<void> _removeEmptyDir() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addDir("admin/test"))
.build();
final appDb = await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDbDir(obj, account, files[0], [files[1]]);
await util.fillAppDbDir(obj, account, files[1], []);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]);
await util.insertDirRelation(c.sqliteDb, account, files[1], const []);
});
final src = FileAppDbDataSource(appDb);
final src = FileSqliteDbDataSource(c);
await src.remove(account, files[1]);
// parent dir is not updated, parent dir is only updated when syncing with
// remote
expect(
await util.listAppDb(
appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)),
[
AppDbDirEntry.fromFiles(account, files[0], [files[1]]),
],
await util.listSqliteDbDirs(c.sqliteDb),
{
files[0]: {files[0]},
},
);
}
/// Remove a dir with file
///
/// Expect: file entries under the dir removed from file2Store
/// Expect: file entries under the dir removed from Files table
Future<void> _removeDir() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
@ -124,25 +149,28 @@ Future<void> _removeDir() async {
..addDir("admin/test")
..addJpeg("admin/test/test1.jpg"))
.build();
final appDb = await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDbDir(obj, account, files[0], [files[1]]);
await util.fillAppDbDir(obj, account, files[1], [files[2]]);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]);
await util.insertDirRelation(c.sqliteDb, account, files[1], [files[2]]);
});
final src = FileAppDbDataSource(appDb);
final src = FileSqliteDbDataSource(c);
await src.remove(account, files[1]);
expect(
(await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e)))
.map((e) => e.file)
.toList(),
[files[0]],
await util.listSqliteDbFiles(c.sqliteDb),
{files[0]},
);
}
/// Remove a dir with file
///
/// Expect: file entries under the dir removed from file2Store
/// Expect: file entries under the dir removed from Files table
Future<void> _removeDirWithSubDir() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
@ -151,75 +179,192 @@ Future<void> _removeDirWithSubDir() async {
..addDir("admin/test/test2")
..addJpeg("admin/test/test2/test3.jpg"))
.build();
final appDb = await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDbDir(obj, account, files[0], [files[1]]);
await util.fillAppDbDir(obj, account, files[1], [files[2]]);
await util.fillAppDbDir(obj, account, files[2], [files[3]]);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]);
await util.insertDirRelation(c.sqliteDb, account, files[1], [files[2]]);
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final src = FileAppDbDataSource(appDb);
final src = FileSqliteDbDataSource(c);
await src.remove(account, files[1]);
expect(
await util.listAppDb(
appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)),
[
AppDbDirEntry.fromFiles(account, files[0], [files[1]]),
],
await util.listSqliteDbDirs(c.sqliteDb),
{
files[0]: {files[0]}
},
);
expect(
(await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e)))
.map((e) => e.file)
.toList(),
[files[0]],
await util.listSqliteDbFiles(c.sqliteDb),
{files[0]},
);
}
/// Update the properties of a file
///
/// Expect: file's property updated in file2Store;
/// file's property updated in dirStore
Future<void> _updateProperty() async {
/// Expect: file's property updated in Files table
Future<void> _updateFileProperty() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg"))
.build();
final appDb = await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDbDir(obj, account, files[0], [files[1]]);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]);
});
final src = FileAppDbDataSource(appDb);
final src = FileSqliteDbDataSource(c);
await src.updateProperty(
account,
files[1],
isArchived: OrNull(true),
overrideDateTime: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5)),
);
final expectFile = files[1].copyWith(
isArchived: OrNull(true),
overrideDateTime: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5)),
);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{files[0], expectFile},
);
}
/// Update metadata of a file
///
/// Expect: Metadata updated in Images table
Future<void> _updateMetadata() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg"))
.build();
files[1] = files[1].copyWith(
metadata: OrNull(Metadata(
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5),
imageWidth: 123,
)),
);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]);
});
final src = FileSqliteDbDataSource(c);
await src.updateProperty(
account,
files[1],
metadata: OrNull(Metadata(
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678),
imageWidth: 123,
lastUpdated: DateTime.utc(2021, 1, 2, 3, 4, 5),
imageWidth: 321,
imageHeight: 123,
)),
isArchived: OrNull(true),
overrideDateTime: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5, 678)),
);
final expectFile = files[1].copyWith(
metadata: OrNull(Metadata(
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5, 678),
imageWidth: 123,
lastUpdated: DateTime.utc(2021, 1, 2, 3, 4, 5),
imageWidth: 321,
imageHeight: 123,
)),
isArchived: OrNull(true),
overrideDateTime: OrNull(DateTime.utc(2020, 1, 2, 3, 4, 5, 678)),
);
expect(
await util.listAppDb(
appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e)),
[
AppDbDirEntry.fromFiles(account, files[0], [expectFile]),
],
);
expect(
(await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e)))
.map((e) => e.file)
.toList(),
[files[0], expectFile],
await util.listSqliteDbFiles(c.sqliteDb),
{files[0], expectFile},
);
}
/// Add metadata to a file
///
/// Expect: Metadata added to Images table
Future<void> _updateAddMetadata() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg"))
.build();
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]);
});
final src = FileSqliteDbDataSource(c);
await src.updateProperty(
account,
files[1],
metadata: OrNull(Metadata(
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5),
imageWidth: 123,
)),
);
final expectFile = files[1].copyWith(
metadata: OrNull(Metadata(
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5),
imageWidth: 123,
)),
);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{files[0], expectFile},
);
}
/// Delete metadata of a file
///
/// Expect: Metadata deleted from Images table
Future<void> _updateDeleteMetadata() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg"))
.build();
files[1] = files[1].copyWith(
metadata: OrNull(Metadata(
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5),
imageWidth: 123,
)),
);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(c.sqliteDb, account, files[0], [files[1]]);
});
final src = FileSqliteDbDataSource(c);
await src.updateProperty(
account,
files[1],
metadata: OrNull(null),
);
final expectFile = files[1].copyWith(
metadata: OrNull(null),
);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{files[0], expectFile},
);
}

View file

@ -0,0 +1,502 @@
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file/file_cache_manager.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/or_null.dart';
import 'package:test/test.dart';
import '../../mock_type.dart';
import '../../test_util.dart' as util;
void main() {
group("FileCacheLoader", () {
group("default", () {
test("no cache", _loaderNoCache);
test("outdated", _loaderOutdatedCache);
test("query remote etag", _loaderQueryRemoteSameEtag);
test("query remote etag (updated)", _loaderQueryRemoteDiffEtag);
});
});
group("FileSqliteCacheUpdater", () {
test("identical", _updaterIdentical);
test("new file", _updaterNewFile);
test("delete file", _updaterDeleteFile);
test("delete dir", _updaterDeleteDir);
test("update file", _updaterUpdateFile);
test("new shared file", _updaterNewSharedFile);
test("new shared dir", _updaterNewSharedDir);
test("delete shared file", _updaterDeleteSharedFile);
test("delete shared dir", _updaterDeleteSharedDir);
});
}
/// Load dir: no cache
///
/// Expect: null
Future<void> _loaderNoCache() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin", etag: "1")
..addJpeg("admin/test1.jpg", etag: "2")
..addDir("admin/test", etag: "3")
..addJpeg("admin/test/test2.jpg", etag: "4"))
.build();
final c = DiContainer(
fileRepo: MockFileMemoryRepo(files),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
});
final cacheSrc = FileSqliteDbDataSource(c);
final remoteSrc = MockFileWebdavDataSource(MockFileMemoryDataSource(files));
final loader = FileCacheLoader(c, cacheSrc: cacheSrc, remoteSrc: remoteSrc);
expect(await loader(account, files[0]), null);
}
/// Load dir: outdated cache
///
/// Expect: return cache;
/// isGood == false
Future<void> _loaderOutdatedCache() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin", etag: "1")
..addJpeg("admin/test1.jpg", etag: "2")
..addDir("admin/test", etag: "3")
..addJpeg("admin/test/test2.jpg", etag: "4"))
.build();
final c = DiContainer(
fileRepo: MockFileMemoryRepo(files),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
final dbFiles = [
files[0].copyWith(etag: OrNull("a")),
...files.slice(1),
];
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, dbFiles);
await util.insertDirRelation(
c.sqliteDb, account, dbFiles[0], dbFiles.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, dbFiles[2], [dbFiles[3]]);
});
final cacheSrc = FileSqliteDbDataSource(c);
final remoteSrc = MockFileWebdavDataSource(MockFileMemoryDataSource(files));
final loader = FileCacheLoader(c, cacheSrc: cacheSrc, remoteSrc: remoteSrc);
expect(
(await loader(account, files[0]))?.toSet(),
dbFiles.slice(0, 3).toSet(),
);
expect(loader.isGood, false);
}
/// Load dir: no etag, up-to-date cache
///
/// Expect: return cache;
/// isGood == true
Future<void> _loaderQueryRemoteSameEtag() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin", etag: "1")
..addJpeg("admin/test1.jpg", etag: "2")
..addDir("admin/test", etag: "3")
..addJpeg("admin/test/test2.jpg", etag: "4"))
.build();
final c = DiContainer(
fileRepo: MockFileMemoryRepo(files),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final cacheSrc = FileSqliteDbDataSource(c);
final remoteSrc = MockFileWebdavDataSource(MockFileMemoryDataSource(files));
final loader = FileCacheLoader(c, cacheSrc: cacheSrc, remoteSrc: remoteSrc);
expect(
(await loader(account, files[0].copyWith(etag: OrNull(null))))?.toSet(),
files.slice(0, 3).toSet(),
);
expect(loader.isGood, true);
}
/// Load dir: no etag, outdated cache
///
/// Expect: return cache;
/// isGood == false
Future<void> _loaderQueryRemoteDiffEtag() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin", etag: "1")
..addJpeg("admin/test1.jpg", etag: "2")
..addDir("admin/test", etag: "3")
..addJpeg("admin/test/test2.jpg", etag: "4"))
.build();
final c = DiContainer(
fileRepo: MockFileMemoryRepo(files),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
final dbFiles = [
files[0].copyWith(etag: OrNull("a")),
...files.slice(1),
];
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, dbFiles);
await util.insertDirRelation(
c.sqliteDb, account, dbFiles[0], dbFiles.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, dbFiles[2], [dbFiles[3]]);
});
final cacheSrc = FileSqliteDbDataSource(c);
final remoteSrc = MockFileWebdavDataSource(MockFileMemoryDataSource(files));
final loader = FileCacheLoader(c, cacheSrc: cacheSrc, remoteSrc: remoteSrc);
expect(
(await loader(account, files[0].copyWith(etag: OrNull(null))))?.toSet(),
dbFiles.slice(0, 3).toSet(),
);
expect(loader.isGood, false);
}
/// Update dir in cache: same set of files
///
/// Expect: nothing happens
Future<void> _updaterIdentical() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(account, files[0], remote: files.slice(0, 3));
expect(
await util.listSqliteDbFiles(c.sqliteDb),
files.toSet(),
);
}
/// Update dir in cache: new file
///
/// Expect: new file added to Files table
Future<void> _updaterNewFile() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final newFile = (util.FilesBuilder(initialFileId: files.length)
..addJpeg("admin/test2.jpg"))
.build()
.first;
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(account, files[0], remote: [...files.slice(0, 3), newFile]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{...files, newFile},
);
}
/// Update dir in cache: file missing
///
/// Expect: missing file deleted from Files table
Future<void> _updaterDeleteFile() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(account, files[0], remote: [files[0], files[2]]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{files[0], ...files.slice(2)},
);
}
/// Update dir in cache: dir missing
///
/// Expect: missing dir deleted from Files table;
/// missing dir deleted from DirFiles table
/// files under dir deleted from Files table;
/// dirs under dir deleted from DirFiles table;
Future<void> _updaterDeleteDir() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(account, files[0], remote: files.slice(0, 2));
expect(
await util.listSqliteDbFiles(c.sqliteDb),
files.slice(0, 2).toSet(),
);
expect(
await util.listSqliteDbDirs(c.sqliteDb),
{
files[0]: files.slice(0, 2).toSet(),
},
);
}
/// Update dir in cache: file updated
///
/// Expect: file updated in Files table
Future<void> _updaterUpdateFile() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg", contentLength: 321)
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final newFile = files[1].copyWith(contentLength: 654);
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(account, files[0],
remote: [files[0], newFile, ...files.slice(2)]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{files[0], newFile, ...files.slice(2)},
);
}
/// Update dir in cache: new shared file
///
/// Expect: file added to AccountFiles table
Future<void> _updaterNewSharedFile() async {
final account = util.buildAccount();
final user1Account = util.buildAccount(username: "user1");
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final user1Files = (util.FilesBuilder(initialFileId: files.length)
..addDir("user1", ownerId: "user1"))
.build();
user1Files
.add(files[1].copyWith(path: "remote.php/dav/files/user1/test1.jpg"));
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(user1Account, user1Files[0], remote: user1Files);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{...files, ...user1Files},
);
}
/// Update dir in cache: new shared dir
///
/// Expect: file added to AccountFiles table
Future<void> _updaterNewSharedDir() async {
final account = util.buildAccount();
final user1Account = util.buildAccount(username: "user1");
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg", ownerId: "user1")
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final user1Files = <File>[];
user1Files.add(files[2].copyWith(path: "remote.php/dav/files/user1/share"));
user1Files.add(
files[3].copyWith(path: "remote.php/dav/files/user1/share/test2.jpg"));
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(user1Account, user1Files[0], remote: user1Files);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{...files, ...user1Files},
);
}
/// Update dir in cache: shared file missing
///
/// Expect: file removed from AccountFiles table;
/// file remained in Files table
Future<void> _updaterDeleteSharedFile() async {
final account = util.buildAccount();
final user1Account = util.buildAccount(username: "user1");
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final user1Files =
(util.FilesBuilder(initialFileId: files.length)..addDir("user1")).build();
user1Files
.add(files[1].copyWith(path: "remote.php/dav/files/user1/test1.jpg"));
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
await util.insertDirRelation(
c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(user1Account, user1Files[0], remote: [user1Files[0]]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{...files, user1Files[0]},
);
}
/// Update dir in cache: shared dir missing
///
/// Expect: file removed from AccountFiles table;
/// file remained in Files table
Future<void> _updaterDeleteSharedDir() async {
final account = util.buildAccount();
final user1Account = util.buildAccount(username: "user1");
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/test")
..addJpeg("admin/test/test2.jpg"))
.build();
final user1Files =
(util.FilesBuilder(initialFileId: files.length)..addDir("user1")).build();
user1Files.add(files[2].copyWith(path: "remote.php/dav/files/user1/share"));
user1Files.add(
files[3].copyWith(path: "remote.php/dav/files/user1/share/test2.jpg"));
final c = DiContainer(
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertDirRelation(
c.sqliteDb, account, files[0], files.slice(1, 3));
await util.insertDirRelation(c.sqliteDb, account, files[2], [files[3]]);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
await util.insertDirRelation(
c.sqliteDb, user1Account, user1Files[0], [user1Files[1]]);
});
final updater = FileSqliteCacheUpdater(c);
await updater(user1Account, user1Files[0], remote: [user1Files[0]]);
expect(
await util.listSqliteDbFiles(c.sqliteDb),
{...files, user1Files[0]},
);
}

View file

@ -752,7 +752,7 @@ void main() {
});
test("etag", () {
final file = src.copyWith(etag: "000");
final file = src.copyWith(etag: OrNull("000"));
expect(
file,
File(

View file

@ -3,26 +3,22 @@ import 'dart:math' as math;
import 'dart:typed_data';
import 'package:event_bus/event_bus.dart';
import 'package:idb_shim/idb.dart';
import 'package:idb_shim/idb_client_memory.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file/data_source.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/future_util.dart' as future_util;
import 'package:nc_photos/or_null.dart';
import 'package:path/path.dart' as path_lib;
/// Mock of [AlbumRepo] where all methods will throw UnimplementedError
class MockAlbumRepo implements AlbumRepo {
@override
Future<void> cleanUp(Account account, String rootDir, List<File> albumFiles) {
throw UnimplementedError();
}
@override
Future<Album> create(Account account, Album album) {
throw UnimplementedError();
@ -36,6 +32,11 @@ class MockAlbumRepo implements AlbumRepo {
throw UnimplementedError();
}
@override
Stream<dynamic> getAll(Account account, List<File> albumFiles) {
throw UnimplementedError();
}
@override
Future<void> update(Account account, Album album) {
throw UnimplementedError();
@ -54,6 +55,17 @@ class MockAlbumMemoryRepo extends MockAlbumRepo {
element.albumFile?.compareServerIdentity(albumFile) == true);
}
@override
getAll(Account account, List<File> albumFiles) async* {
final results = await future_util.waitOr(
albumFiles.map((f) => get(account, f)),
(error, stackTrace) => ExceptionEvent(error, stackTrace),
);
for (final r in results) {
yield r;
}
}
@override
update(Account account, Album album) async {
final i = albums.indexWhere((element) =>
@ -67,120 +79,6 @@ class MockAlbumMemoryRepo extends MockAlbumRepo {
final List<Album> albums;
}
/// Each MockAppDb instance contains a unique memory database
class MockAppDb implements AppDb {
static Future<MockAppDb> create({
bool hasAlbumStore = true,
bool hasFileDb2Store = true,
bool hasDirStore = true,
bool hasMetaStore = true,
// compat
bool hasFileStore = false,
bool hasFileDbStore = false,
}) async {
final inst = MockAppDb();
final db = await inst._dbFactory.open(
"test.db",
version: 1,
onUpgradeNeeded: (event) async {
final db = event.database;
_createDb(
db,
hasAlbumStore: hasAlbumStore,
hasFileDb2Store: hasFileDb2Store,
hasDirStore: hasDirStore,
hasMetaStore: hasMetaStore,
hasFileStore: hasFileStore,
hasFileDbStore: hasFileDbStore,
);
},
);
db.close();
return inst;
}
@override
Future<T> use<T>(Transaction Function(Database db) transactionBuilder,
FutureOr<T> Function(Transaction transaction) fn) async {
final db = await _dbFactory.open(
"test.db",
version: 1,
onUpgradeNeeded: (event) async {
final db = event.database;
_createDb(db);
},
);
Transaction? transaction;
try {
transaction = transactionBuilder(db);
return await fn(transaction);
} finally {
if (transaction != null) {
await transaction.completed;
}
db.close();
}
}
@override
Future<void> delete() async {
await _dbFactory.deleteDatabase("test.db");
}
static void _createDb(
Database db, {
bool hasAlbumStore = true,
bool hasFileDb2Store = true,
bool hasDirStore = true,
bool hasMetaStore = true,
// compat
bool hasFileStore = false,
bool hasFileDbStore = false,
}) {
if (hasAlbumStore) {
final albumStore = db.createObjectStore(AppDb.albumStoreName);
albumStore.createIndex(
AppDbAlbumEntry.indexName, AppDbAlbumEntry.keyPath);
}
if (hasFileDb2Store) {
final file2Store = db.createObjectStore(AppDb.file2StoreName);
file2Store.createIndex(AppDbFile2Entry.strippedPathIndexName,
AppDbFile2Entry.strippedPathKeyPath);
file2Store.createIndex(AppDbFile2Entry.dateTimeEpochMsIndexName,
AppDbFile2Entry.dateTimeEpochMsKeyPath);
}
if (hasDirStore) {
db.createObjectStore(AppDb.dirStoreName);
}
if (hasMetaStore) {
db.createObjectStore(AppDb.metaStoreName,
keyPath: AppDbMetaEntry.keyPath);
}
// compat
if (hasFileStore) {
final fileStore = db.createObjectStore(_fileStoreName);
fileStore.createIndex(_fileIndexName, _fileKeyPath);
}
if (hasFileDbStore) {
final fileDbStore = db.createObjectStore(_fileDbStoreName);
fileDbStore.createIndex(_fileDbIndexName, _fileDbKeyPath, unique: false);
}
}
late final _dbFactory = newIdbFactoryMemory();
// compat only
static const _fileDbStoreName = "filesDb";
static const _fileDbIndexName = "fileDbStore_namespacedFileId";
static const _fileDbKeyPath = "namespacedFileId";
static const _fileStoreName = "files";
static const _fileIndexName = "fileStore_path_index";
static const _fileKeyPath = ["path", "index"];
}
/// EventBus that ignore all events
class MockEventBus implements EventBus {
@override
@ -200,10 +98,10 @@ class MockEventBus implements EventBus {
final _streamController = StreamController.broadcast();
}
/// Mock of [FileRepo] where all methods will throw UnimplementedError
class MockFileRepo implements FileRepo {
/// Mock of [FileDataSource] where all methods will throw UnimplementedError
class MockFileDataSource implements FileDataSource {
@override
Future<void> copy(Object account, File f, String destination,
Future<void> copy(Account account, File f, String destination,
{bool? shouldOverwrite}) {
throw UnimplementedError();
}
@ -214,20 +112,17 @@ class MockFileRepo implements FileRepo {
}
@override
FileDataSource get dataSrc => throw UnimplementedError();
@override
Future<Uint8List> getBinary(Account account, File file) {
Future<Uint8List> getBinary(Account account, File f) {
throw UnimplementedError();
}
@override
Future<List<File>> list(Account account, File root) async {
Future<List<File>> list(Account account, File dir) {
throw UnimplementedError();
}
@override
Future<File> listSingle(Account account, File root) async {
Future<File> listSingle(Account account, File f) {
throw UnimplementedError();
}
@ -243,14 +138,14 @@ class MockFileRepo implements FileRepo {
}
@override
Future<void> remove(Account account, File file) {
Future<void> remove(Account account, File f) {
throw UnimplementedError();
}
@override
Future<void> updateProperty(
Account account,
File file, {
File f, {
OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime,
@ -260,9 +155,9 @@ class MockFileRepo implements FileRepo {
}
}
/// [FileRepo] mock that support some ops with an internal List
class MockFileMemoryRepo extends MockFileRepo {
MockFileMemoryRepo([
/// [FileDataSource] mock that support some ops with an internal List
class MockFileMemoryDataSource extends MockFileDataSource {
MockFileMemoryDataSource([
List<File> initialData = const [],
]) : files = initialData.map((f) => f.copyWith()).toList() {
_id = files
@ -274,7 +169,12 @@ class MockFileMemoryRepo extends MockFileRepo {
@override
list(Account account, File root) async {
return files.where((f) => file_util.isOrUnderDir(f, root)).toList();
return files.where((f) => path_lib.dirname(f.path) == root.path).toList();
}
@override
listSingle(Account account, File file) async {
return files.where((f) => f.strippedPath == file.strippedPath).first;
}
@override
@ -289,9 +189,78 @@ class MockFileMemoryRepo extends MockFileRepo {
}
final List<File> files;
// ignore: unused_field
var _id = 0;
}
class MockFileWebdavDataSource implements FileWebdavDataSource {
const MockFileWebdavDataSource(this.src);
@override
copy(Account account, File f, String destination, {bool? shouldOverwrite}) =>
src.copy(account, f, destination, shouldOverwrite: shouldOverwrite);
@override
createDir(Account account, String path) => src.createDir(account, path);
@override
getBinary(Account account, File f) => src.getBinary(account, f);
@override
list(Account account, File dir, {int? depth}) async {
if (depth == 0) {
return [await src.listSingle(account, dir)];
} else {
return src.list(account, dir);
}
}
@override
listSingle(Account account, File f) => src.listSingle(account, f);
@override
move(Account account, File f, String destination, {bool? shouldOverwrite}) =>
src.move(account, f, destination, shouldOverwrite: shouldOverwrite);
@override
putBinary(Account account, String path, Uint8List content) =>
src.putBinary(account, path, content);
@override
remove(Account account, File f) => src.remove(account, f);
@override
updateProperty(
Account account,
File f, {
OrNull<Metadata>? metadata,
OrNull<bool>? isArchived,
OrNull<DateTime>? overrideDateTime,
bool? favorite,
}) =>
src.updateProperty(
account,
f,
metadata: metadata,
isArchived: isArchived,
overrideDateTime: overrideDateTime,
favorite: favorite,
);
final MockFileMemoryDataSource src;
}
/// [FileRepo] mock that support some ops with an internal List
class MockFileMemoryRepo extends FileRepo {
MockFileMemoryRepo([
List<File> initialData = const [],
]) : super(MockFileMemoryDataSource(initialData));
List<File> get files {
return (dataSrc as MockFileMemoryDataSource).files;
}
}
/// Mock of [ShareRepo] where all methods will throw UnimplementedError
class MockShareRepo implements ShareRepo {
@override
@ -426,6 +395,4 @@ extension MockDiContainerExtension on DiContainer {
MockShareMemoryRepo get shareMemoryRepo => shareRepo as MockShareMemoryRepo;
MockShareeMemoryRepo get shareeMemoryRepo =>
shareeRepo as MockShareeMemoryRepo;
MockAppDb get appMemeoryDb => appDb as MockAppDb;
}

View file

@ -1,9 +1,9 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart' as sql;
import 'package:drift/native.dart' as sql;
import 'package:flutter/foundation.dart';
import 'package:idb_shim/idb.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/ci_string.dart';
import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/cover_provider.dart';
@ -13,8 +13,12 @@ import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/share.dart';
import 'package:nc_photos/entity/sharee.dart';
import 'package:nc_photos/entity/sqlite_table.dart' as sql;
import 'package:nc_photos/entity/sqlite_table_converter.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/iterable_extension.dart';
import 'package:nc_photos/type.dart';
import 'package:nc_photos/or_null.dart';
import 'package:tuple/tuple.dart';
class FilesBuilder {
FilesBuilder({
@ -29,21 +33,25 @@ class FilesBuilder {
String relativePath, {
int? contentLength,
String? contentType,
String? etag,
DateTime? lastModified,
bool isCollection = false,
bool hasPreview = true,
String ownerId = "admin",
Metadata? metadata,
}) {
files.add(File(
path: "remote.php/dav/files/$relativePath",
contentLength: contentLength,
contentType: contentType,
etag: etag,
lastModified:
lastModified ?? DateTime.utc(2020, 1, 2, 3, 4, 5 + files.length),
isCollection: isCollection,
hasPreview: hasPreview,
fileId: fileId++,
ownerId: ownerId.toCi(),
metadata: metadata,
));
}
@ -51,6 +59,7 @@ class FilesBuilder {
String relativePath,
String contentType, {
int contentLength = 1024,
String? etag,
DateTime? lastModified,
bool hasPreview = true,
String ownerId = "admin",
@ -59,6 +68,7 @@ class FilesBuilder {
relativePath,
contentLength: contentLength,
contentType: contentType,
etag: etag,
lastModified: lastModified,
hasPreview: hasPreview,
ownerId: ownerId,
@ -67,33 +77,62 @@ class FilesBuilder {
void addJpeg(
String relativePath, {
int contentLength = 1024,
String? etag,
DateTime? lastModified,
bool hasPreview = true,
String ownerId = "admin",
OrNull<Metadata>? metadata,
}) =>
add(
relativePath,
contentLength: contentLength,
contentType: "image/jpeg",
etag: etag,
lastModified: lastModified,
hasPreview: hasPreview,
ownerId: ownerId,
metadata: metadata?.obj ??
Metadata(
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5),
imageWidth: 640,
imageHeight: 480,
),
);
void addDir(
String relativePath, {
int contentLength = 1024,
String? etag,
DateTime? lastModified,
String ownerId = "admin",
}) =>
add(
relativePath,
etag: etag,
lastModified: lastModified,
isCollection: true,
hasPreview: false,
ownerId: ownerId,
);
void addAlbumJson(
String homeDir,
String filename, {
int contentLength = 1024,
String? etag,
DateTime? lastModified,
String ownerId = "admin",
}) =>
add(
"$homeDir/.com.nkming.nc_photos/albums/$filename.nc_album.json",
contentLength: contentLength,
contentType: "application/json",
etag: etag,
lastModified: lastModified,
hasPreview: false,
ownerId: ownerId,
);
final files = <File>[];
int fileId;
}
@ -339,45 +378,189 @@ Sharee buildSharee({
shareWith: shareWith,
);
Future<void> fillAppDb(
AppDb appDb, Account account, Iterable<File> files) async {
await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite),
(transaction) async {
final file2Store = transaction.objectStore(AppDb.file2StoreName);
for (final f in files) {
await file2Store.put(AppDbFile2Entry.fromFile(account, f).toJson(),
AppDbFile2Entry.toPrimaryKeyForFile(account, f));
}
},
sql.SqliteDb buildTestDb() {
sql.driftRuntimeOptions.debugPrint = _debugPrintSql;
return sql.SqliteDb(
executor: sql.NativeDatabase.memory(
logStatements: true,
),
);
}
Future<void> fillAppDbDir(
AppDb appDb, Account account, File dir, List<File> children) async {
await appDb.use(
(db) => db.transaction(AppDb.dirStoreName, idbModeReadWrite),
(transaction) async {
final dirStore = transaction.objectStore(AppDb.dirStoreName);
await dirStore.put(
AppDbDirEntry.fromFiles(account, dir, children).toJson(),
AppDbDirEntry.toPrimaryKeyForDir(account, dir));
},
);
Future<void> insertFiles(
sql.SqliteDb db, Account account, Iterable<File> files) async {
final dbAccount = await db.accountOf(account);
for (final f in files) {
final sharedQuery = db.selectOnly(db.files).join([
sql.innerJoin(
db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId),
useColumns: false),
])
..addColumns([db.files.rowId])
..where(db.accountFiles.account.equals(dbAccount.rowId).not())
..where(db.files.fileId.equals(f.fileId!));
var rowId = (await sharedQuery.map((r) => r.read(db.files.rowId)).get())
.firstOrNull;
final insert = SqliteFileConverter.toSql(dbAccount, f);
if (rowId == null) {
final dbFile = await db.into(db.files).insertReturning(insert.file);
rowId = dbFile.rowId;
}
final dbAccountFile = await db
.into(db.accountFiles)
.insertReturning(insert.accountFile.copyWith(file: sql.Value(rowId)));
if (insert.image != null) {
await db.into(db.images).insert(
insert.image!.copyWith(accountFile: sql.Value(dbAccountFile.rowId)));
}
if (insert.trash != null) {
await db
.into(db.trashes)
.insert(insert.trash!.copyWith(file: sql.Value(rowId)));
}
}
}
Future<List<T>> listAppDb<T>(
AppDb appDb, String storeName, T Function(JsonObj) transform) {
return appDb.use(
(db) => db.transaction(storeName, idbModeReadOnly),
(transaction) async {
final store = transaction.objectStore(storeName);
return await store
.openCursor(autoAdvance: true)
.map((c) => c.value)
.cast<Map>()
.map((e) => transform(e.cast<String, dynamic>()))
.toList();
},
);
Future<void> insertDirRelation(
sql.SqliteDb db, Account account, File dir, Iterable<File> children) async {
final dbAccount = await db.accountOf(account);
final dirRowIds = (await db
.accountFileRowIdsByFileIds([dir.fileId!], sqlAccount: dbAccount))
.first;
final childRowIds = await db.accountFileRowIdsByFileIds(
[dir, ...children].map((f) => f.fileId!),
sqlAccount: dbAccount);
await db.batch((batch) {
batch.insertAll(
db.dirFiles,
childRowIds.map((c) => sql.DirFilesCompanion.insert(
dir: dirRowIds.fileRowId,
child: c.fileRowId,
)),
);
});
}
Future<void> insertAlbums(
sql.SqliteDb db, Account account, Iterable<Album> albums) async {
final dbAccount = await db.accountOf(account);
for (final a in albums) {
final rowIds =
await db.accountFileRowIdsOf(a.albumFile!, sqlAccount: dbAccount);
final insert = SqliteAlbumConverter.toSql(a, rowIds.fileRowId);
final dbAlbum = await db.into(db.albums).insertReturning(insert.album);
for (final s in insert.albumShares) {
await db
.into(db.albumShares)
.insert(s.copyWith(album: sql.Value(dbAlbum.rowId)));
}
}
}
Future<Set<File>> listSqliteDbFiles(sql.SqliteDb db) async {
final query = db.select(db.files).join([
sql.innerJoin(
db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId)),
sql.innerJoin(
db.accounts, db.accounts.rowId.equalsExp(db.accountFiles.account)),
sql.leftOuterJoin(
db.images, db.images.accountFile.equalsExp(db.accountFiles.rowId)),
sql.leftOuterJoin(db.trashes, db.trashes.file.equalsExp(db.files.rowId)),
]);
return (await query
.map((r) => SqliteFileConverter.fromSql(
r.readTable(db.accounts).userId,
sql.CompleteFile(
r.readTable(db.files),
r.readTable(db.accountFiles),
r.readTableOrNull(db.images),
r.readTableOrNull(db.trashes),
),
))
.get())
.toSet();
}
Future<Map<File, Set<File>>> listSqliteDbDirs(sql.SqliteDb db) async {
final query = db.select(db.files).join([
sql.innerJoin(
db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId)),
sql.innerJoin(
db.accounts, db.accounts.rowId.equalsExp(db.accountFiles.account)),
sql.leftOuterJoin(
db.images, db.images.accountFile.equalsExp(db.accountFiles.rowId)),
sql.leftOuterJoin(db.trashes, db.trashes.file.equalsExp(db.files.rowId)),
]);
final fileMap = Map.fromEntries(await query.map((r) {
final f = sql.CompleteFile(
r.readTable(db.files),
r.readTable(db.accountFiles),
r.readTableOrNull(db.images),
r.readTableOrNull(db.trashes),
);
return MapEntry(
f.file.rowId,
SqliteFileConverter.fromSql(r.readTable(db.accounts).userId, f),
);
}).get());
final dirQuery = db.select(db.dirFiles);
final dirs = await dirQuery.map((r) => Tuple2(r.dir, r.child)).get();
final result = <File, Set<File>>{};
for (final d in dirs) {
(result[fileMap[d.item1]!] ??= <File>{}).add(fileMap[d.item2]!);
}
return result;
}
Future<Set<Album>> listSqliteDbAlbums(sql.SqliteDb db) async {
final albumQuery = db.select(db.albums).join([
sql.innerJoin(db.files, db.files.rowId.equalsExp(db.albums.file)),
sql.innerJoin(
db.accountFiles, db.accountFiles.file.equalsExp(db.files.rowId)),
sql.innerJoin(
db.accounts, db.accounts.rowId.equalsExp(db.accountFiles.account)),
]);
final albums = await albumQuery.map((r) {
final albumFile = SqliteFileConverter.fromSql(
r.readTable(db.accounts).userId,
sql.CompleteFile(
r.readTable(db.files),
r.readTable(db.accountFiles),
null,
null,
),
);
return Tuple2(
r.read(db.albums.rowId),
SqliteAlbumConverter.fromSql(r.readTable(db.albums), albumFile, []),
);
}).get();
final results = <Album>{};
for (final a in albums) {
final shareQuery = db.select(db.albumShares)
..where((t) => t.album.equals(a.item1));
final dbShares = await shareQuery.get();
results.add(a.item2.copyWith(
lastUpdated: OrNull(null),
shares: dbShares.isEmpty
? null
: OrNull(dbShares
.map((s) => AlbumShare(
userId: s.userId.toCi(),
displayName: s.displayName,
sharedAt: s.sharedAt))
.toList()),
));
}
return results;
}
bool shouldPrintSql = false;
void _debugPrintSql(String log) {
if (shouldPrintSql) {
debugPrint(log, wrapWidth: 1024);
}
}

View file

@ -8,7 +8,7 @@ import 'package:nc_photos/entity/album/item.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/use_case/add_to_album.dart';
@ -44,13 +44,17 @@ Future<void> _addFile() async {
final album = util.AlbumBuilder().build();
final albumFile = album.albumFile!;
final c = DiContainer(
fileRepo: MockFileMemoryRepo(),
albumRepo: MockAlbumMemoryRepo([album]),
shareRepo: MockShareRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, [file]);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, [file]);
});
await AddToAlbum(c)(
account,
@ -80,7 +84,7 @@ Future<void> _addFile() async {
addedBy: "admin".toCi(),
addedAt: DateTime.utc(2020, 1, 2, 3, 4, 5),
file: file,
),
).minimize(),
],
latestItemTime: DateTime.utc(2020, 1, 2, 3, 4, 5),
),
@ -106,13 +110,17 @@ Future<void> _addExistingFile() async {
final newFile = files[0].copyWith();
final albumFile = album.albumFile!;
final c = DiContainer(
fileRepo: MockFileMemoryRepo(),
albumRepo: MockAlbumMemoryRepo([album]),
shareRepo: MockShareRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await AddToAlbum(c)(
account,
@ -188,14 +196,19 @@ Future<void> _addExistingSharedFile() async {
final album = (util.AlbumBuilder()..addFileItem(files[0])).build();
final albumFile = album.albumFile!;
final c = DiContainer(
fileRepo: MockFileMemoryRepo(),
albumRepo: MockAlbumMemoryRepo([album]),
shareRepo: MockShareRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDb(obj, user1Account, user1Files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
});
await AddToAlbum(c)(
account,
@ -247,17 +260,21 @@ Future<void> _addFileToSharedAlbumOwned() async {
final album = (util.AlbumBuilder()..addShare("user1")).build();
final albumFile = album.albumFile!;
final c = DiContainer(
fileRepo: MockFileMemoryRepo(),
albumRepo: MockAlbumMemoryRepo([album]),
shareRepo: MockShareMemoryRepo([
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, [file]);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider({
"isLabEnableSharedAlbum": true,
})),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, [file]);
});
await AddToAlbum(c)(
account,
@ -290,17 +307,21 @@ Future<void> _addFileOwnedByUserToSharedAlbumOwned() async {
final album = (util.AlbumBuilder()..addShare("user1")).build();
final albumFile = album.albumFile!;
final c = DiContainer(
fileRepo: MockFileMemoryRepo(),
albumRepo: MockAlbumMemoryRepo([album]),
shareRepo: MockShareMemoryRepo([
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, [file]);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider({
"isLabEnableSharedAlbum": true,
})),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, [file]);
});
await AddToAlbum(c)(
account,
@ -335,6 +356,7 @@ Future<void> _addFileToMultiuserSharedAlbumNotOwned() async {
.build();
final albumFile = album.albumFile!;
final c = DiContainer(
fileRepo: MockFileMemoryRepo(),
albumRepo: MockAlbumMemoryRepo([album]),
shareRepo: MockShareMemoryRepo([
util.buildShare(
@ -342,13 +364,16 @@ Future<void> _addFileToMultiuserSharedAlbumNotOwned() async {
util.buildShare(
id: "1", file: albumFile, uidOwner: "user1", shareWith: "user2"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, [file]);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider({
"isLabEnableSharedAlbum": true,
})),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, [file]);
});
await AddToAlbum(c)(
account,

View file

@ -1,230 +0,0 @@
import 'package:idb_shim/idb_client.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/list_extension.dart';
import 'package:nc_photos/use_case/compat/v37.dart';
import 'package:test/test.dart';
import '../../mock_type.dart';
import '../../test_util.dart' as util;
void main() {
group("CompatV37", () {
group("isAppDbNeedMigration", () {
test("w/ meta entry == false", _isAppDbNeedMigrationEntryFalse);
test("w/ meta entry == true", _isAppDbNeedMigrationEntryTrue);
test("w/o meta entry", _isAppDbNeedMigrationWithoutEntry);
});
group("migrateAppDb", () {
test("w/o nomedia", _migrateAppDbWithoutNomedia);
test("w/ nomedia", _migrateAppDb);
test("w/ nomedia nested dir", _migrateAppDbNestedDir);
test("w/ nomedia nested no media marker", _migrateAppDbNestedMarker);
test("w/ nomedia root", _migrateAppDbRoot);
});
});
}
/// Check if migration is necessary with isMigrated flag = false
///
/// Expect: true
Future<void> _isAppDbNeedMigrationEntryFalse() async {
final appDb = MockAppDb();
await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
const entry = AppDbMetaEntryCompatV37(false);
await metaStore.put(entry.toEntry().toJson());
},
);
expect(await CompatV37.isAppDbNeedMigration(appDb), true);
}
/// Check if migration is necessary with isMigrated flag = true
///
/// Expect: false
Future<void> _isAppDbNeedMigrationEntryTrue() async {
final appDb = MockAppDb();
await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
const entry = AppDbMetaEntryCompatV37(true);
await metaStore.put(entry.toEntry().toJson());
},
);
expect(await CompatV37.isAppDbNeedMigration(appDb), false);
}
/// Check if migration is necessary with isMigrated flag missing
///
/// Expect: false
Future<void> _isAppDbNeedMigrationWithoutEntry() async {
final appDb = MockAppDb();
await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
const entry = AppDbMetaEntryCompatV37(true);
await metaStore.put(entry.toEntry().toJson());
},
);
expect(await CompatV37.isAppDbNeedMigration(appDb), false);
}
/// Migrate db without nomedia file
///
/// Expect: all files remain
Future<void> _migrateAppDbWithoutNomedia() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/dir1")
..addJpeg("admin/dir1/test2.jpg"))
.build();
final appDb = MockAppDb();
await util.fillAppDb(appDb, account, files);
await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3));
await util.fillAppDbDir(appDb, account, files[2], [files[3]]);
await CompatV37.migrateAppDb(appDb);
final fileObjs = await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file);
expect(fileObjs, files);
final dirEntries = await util.listAppDb(
appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e));
expect(dirEntries, [
AppDbDirEntry.fromFiles(account, files[0], files.slice(1, 3)),
AppDbDirEntry.fromFiles(account, files[2], [files[3]]),
]);
}
/// Migrate db with nomedia file
///
/// nomedia: admin/dir1/.nomedia
/// Expect: files (except .nomedia) under admin/dir1 removed
Future<void> _migrateAppDb() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/dir1")
..addGenericFile("admin/dir1/.nomedia", "text/plain")
..addJpeg("admin/dir1/test2.jpg"))
.build();
final appDb = MockAppDb();
await util.fillAppDb(appDb, account, files);
await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3));
await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 5));
await CompatV37.migrateAppDb(appDb);
final fileObjs = await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file);
expect(fileObjs, files.slice(0, 4));
final dirEntries = await util.listAppDb(
appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e));
expect(dirEntries, [
AppDbDirEntry.fromFiles(account, files[0], files.slice(1, 3)),
AppDbDirEntry.fromFiles(account, files[2], [files[3]]),
]);
}
/// Migrate db with nomedia file
///
/// nomedia: admin/dir1/.nomedia
/// Expect: files (except .nomedia) under admin/dir1 removed
Future<void> _migrateAppDbNestedDir() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/dir1")
..addGenericFile("admin/dir1/.nomedia", "text/plain")
..addJpeg("admin/dir1/test2.jpg")
..addDir("admin/dir1/dir1-1")
..addJpeg("admin/dir1/dir1-1/test3.jpg"))
.build();
final appDb = MockAppDb();
await util.fillAppDb(appDb, account, files);
await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3));
await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 6));
await util.fillAppDbDir(appDb, account, files[5], [files[6]]);
await CompatV37.migrateAppDb(appDb);
final fileObjs = await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file);
expect(fileObjs, files.slice(0, 4));
final dirEntries = await util.listAppDb(
appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e));
expect(dirEntries, [
AppDbDirEntry.fromFiles(account, files[0], files.slice(1, 3)),
AppDbDirEntry.fromFiles(account, files[2], [files[3]]),
]);
}
/// Migrate db with nomedia file
///
/// nomedia: admin/dir1/.nomedia, admin/dir1/dir1-1/.nomedia
/// Expect: files (except admin/dir1/.nomedia) under admin/dir1 removed
Future<void> _migrateAppDbNestedMarker() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addJpeg("admin/test1.jpg")
..addDir("admin/dir1")
..addGenericFile("admin/dir1/.nomedia", "text/plain")
..addJpeg("admin/dir1/test2.jpg")
..addDir("admin/dir1/dir1-1")
..addGenericFile("admin/dir1/dir1-1/.nomedia", "text/plain")
..addJpeg("admin/dir1/dir1-1/test3.jpg"))
.build();
final appDb = MockAppDb();
await util.fillAppDb(appDb, account, files);
await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 3));
await util.fillAppDbDir(appDb, account, files[2], files.slice(3, 6));
await util.fillAppDbDir(appDb, account, files[5], files.slice(6, 8));
await CompatV37.migrateAppDb(appDb);
final fileObjs = await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file);
expect(fileObjs, files.slice(0, 4));
final dirEntries = await util.listAppDb(
appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e));
expect(dirEntries, [
AppDbDirEntry.fromFiles(account, files[0], files.slice(1, 3)),
AppDbDirEntry.fromFiles(account, files[2], [files[3]]),
]);
}
/// Migrate db with nomedia file
///
/// nomedia: admin/.nomedia
/// Expect: files (except .nomedia) under admin removed
Future<void> _migrateAppDbRoot() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addDir("admin")
..addGenericFile("admin/.nomedia", "text/plain")
..addJpeg("admin/test1.jpg")
..addDir("admin/dir1")
..addJpeg("admin/dir1/test2.jpg"))
.build();
final appDb = MockAppDb();
await util.fillAppDb(appDb, account, files);
await util.fillAppDbDir(appDb, account, files[0], files.slice(1, 4));
await util.fillAppDbDir(appDb, account, files[3], [files[4]]);
await CompatV37.migrateAppDb(appDb);
final objs = await util.listAppDb(
appDb, AppDb.file2StoreName, (e) => AppDbFile2Entry.fromJson(e).file);
expect(objs, files.slice(0, 2));
final dirEntries = await util.listAppDb(
appDb, AppDb.dirStoreName, (e) => AppDbDirEntry.fromJson(e));
expect(dirEntries, [
AppDbDirEntry.fromFiles(account, files[0], [files[1]]),
]);
}

View file

@ -1,93 +0,0 @@
import 'package:idb_shim/idb_client.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_db.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/use_case/db_compat/v5.dart';
import 'package:test/test.dart';
import '../../mock_type.dart';
import '../../test_util.dart' as util;
void main() {
group("DbCompatV5", () {
group("isNeedMigration", () {
test("w/ meta entry == false", () async {
final appDb = MockAppDb();
await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
const entry = AppDbMetaEntryDbCompatV5(false);
await metaStore.put(entry.toEntry().toJson());
},
);
expect(await DbCompatV5.isNeedMigration(appDb), true);
});
test("w/ meta entry == true", () async {
final appDb = MockAppDb();
await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
const entry = AppDbMetaEntryDbCompatV5(true);
await metaStore.put(entry.toEntry().toJson());
},
);
expect(await DbCompatV5.isNeedMigration(appDb), false);
});
test("w/o meta entry", () async {
final appDb = MockAppDb();
await appDb.use(
(db) => db.transaction(AppDb.metaStoreName, idbModeReadWrite),
(transaction) async {
final metaStore = transaction.objectStore(AppDb.metaStoreName);
const entry = AppDbMetaEntryDbCompatV5(true);
await metaStore.put(entry.toEntry().toJson());
},
);
expect(await DbCompatV5.isNeedMigration(appDb), false);
});
});
test("migrate", () async {
final account = util.buildAccount();
final files = (util.FilesBuilder()
..addJpeg(
"admin/test1.jpg",
lastModified: DateTime.utc(2020, 1, 2, 3, 4, 5),
))
.build();
final appDb = MockAppDb();
await appDb.use(
(db) => db.transaction(AppDb.file2StoreName, idbModeReadWrite),
(transaction) async {
final fileStore = transaction.objectStore(AppDb.file2StoreName);
await fileStore.put({
"server": account.url,
"userId": account.username.toCaseInsensitiveString(),
"strippedPath": files[0].strippedPathWithEmpty,
"file": files[0].toJson(),
}, "${account.url}/${account.username.toCaseInsensitiveString()}/${files[0].fileId}");
},
);
await DbCompatV5.migrate(appDb);
final objs =
await util.listAppDb(appDb, AppDb.file2StoreName, (item) => item);
expect(objs, [
{
"server": account.url,
"userId": account.username.toCaseInsensitiveString(),
"strippedPath": files[0].strippedPathWithEmpty,
"dateTimeEpochMs": 1577934245000,
"file": files[0].toJson(),
}
]);
});
});
}

View file

@ -1,9 +1,8 @@
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/use_case/find_file.dart';
import 'package:test/test.dart';
import '../mock_type.dart';
import '../test_util.dart' as util;
void main() {
@ -23,10 +22,13 @@ Future<void> _findFile() async {
..addJpeg("admin/test2.jpg"))
.build();
final c = DiContainer(
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
expect(await FindFile(c)(account, [1]), [files[1]]);
}
@ -38,10 +40,13 @@ Future<void> _findMissingFile() async {
final account = util.buildAccount();
final files = (util.FilesBuilder()..addJpeg("admin/test1.jpg")).build();
final c = DiContainer(
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
expect(() => FindFile(c)(account, [1]), throwsStateError);
}

View file

@ -30,7 +30,7 @@ Future<void> _root() async {
expect(
await Ls(fileRepo)(
account, File(path: file_util.unstripPath(account, "."))),
files.slice(1, 4),
files.slice(1, 3),
);
}

View file

@ -1,7 +1,7 @@
import 'package:event_bus/event_bus.dart';
import 'package:kiwi/kiwi.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/use_case/remove_album.dart';
import 'package:test/test.dart';
@ -35,9 +35,10 @@ Future<void> _removeAlbum() async {
albumRepo: MockAlbumMemoryRepo([album1, album2]),
fileRepo: MockFileMemoryRepo([albumFile1, albumFile2]),
shareRepo: MockShareRepo(),
appDb: MockAppDb(),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await RemoveAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile1.path));
@ -65,11 +66,12 @@ Future<void> _removeSharedAlbum() async {
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
util.buildShare(id: "1", file: files[0], shareWith: "user1"),
]),
appDb: MockAppDb(),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider({
"isLabEnableSharedAlbum": true,
})),
);
addTearDown(() => c.sqliteDb.close());
await RemoveAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile.path));
@ -106,11 +108,12 @@ Future<void> _removeSharedAlbumFileInOtherAlbum() async {
util.buildShare(id: "1", file: files[0], shareWith: "user1"),
util.buildShare(id: "2", file: albumFiles[1], shareWith: "user1"),
]),
appDb: MockAppDb(),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider({
"isLabEnableSharedAlbum": true,
})),
);
addTearDown(() => c.sqliteDb.close());
await RemoveAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFiles[0].path));
@ -146,14 +149,19 @@ Future<void> _removeSharedAlbumResyncedFile() async {
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
util.buildShare(id: "1", file: files[0], shareWith: "user1"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDb(obj, user1Account, user1Files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider({
"isLabEnableSharedAlbum": true,
})),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
});
await RemoveAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile.path));

View file

@ -5,7 +5,7 @@ import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/cover_provider.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/use_case/remove_from_album.dart';
import 'package:test/test.dart';
@ -52,10 +52,13 @@ Future<void> _removeLastFile() async {
albumRepo: MockAlbumMemoryRepo([album]),
fileRepo: MockFileMemoryRepo([albumFile, file1]),
shareRepo: MockShareRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]);
@ -100,10 +103,13 @@ Future<void> _remove1OfNFiles() async {
albumRepo: MockAlbumMemoryRepo([album]),
fileRepo: MockFileMemoryRepo([albumFile, ...files]),
shareRepo: MockShareRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(account,
c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItems[0]]);
@ -119,7 +125,7 @@ Future<void> _remove1OfNFiles() async {
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5),
name: "test",
provider: AlbumStaticProvider(
items: [fileItems[1], fileItems[2]],
items: [fileItems[1].minimize(), fileItems[2].minimize()],
latestItemTime: files[2].lastModified,
),
coverProvider: AlbumAutoCoverProvider(coverFile: files[2]),
@ -154,10 +160,13 @@ Future<void> _removeLatestOfNFiles() async {
albumRepo: MockAlbumMemoryRepo([album]),
fileRepo: MockFileMemoryRepo([albumFile, ...files]),
shareRepo: MockShareRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(account,
c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItems[0]]);
@ -173,7 +182,7 @@ Future<void> _removeLatestOfNFiles() async {
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5),
name: "test",
provider: AlbumStaticProvider(
items: [fileItems[1], fileItems[2]],
items: [fileItems[1].minimize(), fileItems[2].minimize()],
latestItemTime: files[1].lastModified,
),
coverProvider: AlbumAutoCoverProvider(coverFile: files[1]),
@ -205,10 +214,13 @@ Future<void> _removeManualCoverFile() async {
albumRepo: MockAlbumMemoryRepo([album]),
fileRepo: MockFileMemoryRepo([albumFile, ...files]),
shareRepo: MockShareRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(account,
c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItems[0]]);
@ -224,7 +236,7 @@ Future<void> _removeManualCoverFile() async {
lastUpdated: DateTime.utc(2020, 1, 2, 3, 4, 5),
name: "test",
provider: AlbumStaticProvider(
items: [fileItems[1], fileItems[2]],
items: [fileItems[1].minimize(), fileItems[2].minimize()],
latestItemTime: files[2].lastModified,
),
coverProvider: AlbumAutoCoverProvider(coverFile: files[2]),
@ -256,10 +268,13 @@ Future<void> _removeFromSharedAlbumOwned() async {
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
util.buildShare(id: "1", file: file1, shareWith: "user1"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]);
@ -299,10 +314,14 @@ Future<void> _removeFromSharedAlbumOwnedWithOtherShare() async {
util.buildShare(
id: "3", uidOwner: "user1", file: file1, shareWith: "user2"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, user1Account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, user1Account, files);
});
await RemoveFromAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]);
@ -343,10 +362,13 @@ Future<void> _removeFromSharedAlbumOwnedLeaveExtraShare() async {
util.buildShare(id: "1", file: file1, shareWith: "user1"),
util.buildShare(id: "2", file: file1, shareWith: "user2"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]);
@ -389,10 +411,13 @@ Future<void> _removeFromSharedAlbumOwnedFileInOtherAlbum() async {
util.buildShare(id: "2", file: files[0], shareWith: "user2"),
util.buildShare(id: "3", file: album2File, shareWith: "user1"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(account,
c.albumMemoryRepo.findAlbumByPath(album1File.path), [album1fileItems[0]]);
@ -432,10 +457,13 @@ Future<void> _removeFromSharedAlbumNotOwned() async {
util.buildShare(id: "2", file: file1, shareWith: "user1"),
util.buildShare(id: "3", file: file1, shareWith: "user2"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]);
@ -479,10 +507,13 @@ Future<void> _removeFromSharedAlbumNotOwnedWithOwnerShare() async {
util.buildShare(
id: "3", uidOwner: "user1", file: file1, shareWith: "user2"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await RemoveFromAlbum(c)(
account, c.albumMemoryRepo.findAlbumByPath(albumFile.path), [fileItem1]);

View file

@ -5,7 +5,7 @@ import 'package:nc_photos/entity/album.dart';
import 'package:nc_photos/entity/album/cover_provider.dart';
import 'package:nc_photos/entity/album/provider.dart';
import 'package:nc_photos/entity/album/sort_provider.dart';
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/or_null.dart';
import 'package:nc_photos/pref.dart';
import 'package:nc_photos/use_case/remove.dart';
@ -45,11 +45,14 @@ Future<void> _removeFile() async {
albumRepo: MockAlbumMemoryRepo(),
fileRepo: MockFileMemoryRepo(files),
shareRepo: MockShareMemoryRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await Remove(c)(account, [files[0]]);
expect(c.fileMemoryRepo.files, [files[1]]);
@ -68,11 +71,14 @@ Future<void> _removeFileNoCleanUp() async {
albumRepo: MockAlbumMemoryRepo(),
fileRepo: MockFileMemoryRepo(files),
shareRepo: MockShareMemoryRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await Remove(c)(account, [files[0]], shouldCleanUp: false);
expect(c.fileMemoryRepo.files, [files[1]]);
@ -91,11 +97,14 @@ Future<void> _removeAlbumFile() async {
albumRepo: MockAlbumMemoryRepo([album]),
fileRepo: MockFileMemoryRepo([albumFile, ...files]),
shareRepo: MockShareMemoryRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await Remove(c)(account, [files[0]]);
expect(
@ -132,11 +141,14 @@ Future<void> _removeAlbumFileNoCleanUp() async {
albumRepo: MockAlbumMemoryRepo([album]),
fileRepo: MockFileMemoryRepo([albumFile, ...files]),
shareRepo: MockShareMemoryRepo(),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await Remove(c)(account, [files[0]], shouldCleanUp: false);
expect(
@ -182,11 +194,14 @@ Future<void> _removeSharedAlbumFile() async {
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
util.buildShare(id: "1", file: files[0], shareWith: "user1"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await Remove(c)(account, [files[0]]);
expect(
@ -246,12 +261,17 @@ Future<void> _removeSharedAlbumSharedFile() async {
id: "2", file: user1Files[0], uidOwner: "user1", shareWith: "admin"),
util.buildShare(id: "3", file: files[0], shareWith: "user2"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDb(obj, user1Account, user1Files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, account, files);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
});
await Remove(c)(account, [files[0]]);
expect(
@ -309,11 +329,14 @@ Future<void> _removeSharedAlbumResyncedFile() async {
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
util.buildShare(id: "1", file: files[0], shareWith: "user1"),
]),
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
pref: Pref.scoped(PrefMemoryProvider()),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
await Remove(c)(account, [files[0]]);
expect(

View file

@ -1,11 +1,10 @@
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/entity/file.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/object_extension.dart';
import 'package:nc_photos/entity/sqlite_table_extension.dart' as sql;
import 'package:nc_photos/use_case/scan_dir_offline.dart';
import 'package:test/test.dart';
import '../mock_type.dart';
import '../test_util.dart' as util;
void main() {
@ -33,10 +32,13 @@ Future<void> _root() async {
..addJpeg("admin/test/test2.jpg"))
.build();
final c = DiContainer(
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
// convert to set because ScanDirOffline does not guarantee order
expect(
@ -59,10 +61,13 @@ Future<void> _subDir() async {
..addJpeg("admin/test/test2.jpg"))
.build();
final c = DiContainer(
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
expect(
(await ScanDirOffline(c)(
@ -84,10 +89,13 @@ Future<void> _unsupportedFile() async {
..addGenericFile("admin/test2.pdf", "application/pdf"))
.build();
final c = DiContainer(
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
});
// convert to set because ScanDirOffline does not guarantee order
expect(
@ -118,11 +126,15 @@ Future<void> _multiAccountRoot() async {
..addJpeg("user1/test/test2.jpg", ownerId: "user1"))
.build();
final c = DiContainer(
appDb: await MockAppDb().applyFuture((obj) async {
await util.fillAppDb(obj, account, files);
await util.fillAppDb(obj, user1Account, user1Files);
}),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await c.sqliteDb.transaction(() async {
await c.sqliteDb.insertAccountOf(account);
await util.insertFiles(c.sqliteDb, account, files);
await c.sqliteDb.insertAccountOf(user1Account);
await util.insertFiles(c.sqliteDb, user1Account, user1Files);
});
expect(
(await ScanDirOffline(c)(

View file

@ -36,8 +36,9 @@ Future<void> _unshareWithoutFile() async {
util.buildShare(id: "0", file: albumFile, shareWith: "user1"),
util.buildShare(id: "1", file: albumFile, shareWith: "user2"),
]),
appDb: MockAppDb(),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await UnshareAlbumWithUser(c)(account,
c.albumMemoryRepo.findAlbumByPath(albumFile.path), "user1".toCi());
@ -74,8 +75,9 @@ Future<void> _unshareWithFile() async {
util.buildShare(id: "2", file: file1, shareWith: "user1"),
util.buildShare(id: "3", file: file1, shareWith: "user2"),
]),
appDb: MockAppDb(),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await UnshareAlbumWithUser(c)(account,
c.albumMemoryRepo.findAlbumByPath(albumFile.path), "user1".toCi());
@ -123,8 +125,9 @@ Future<void> _unshareWithFileNotOwned() async {
util.buildShare(
id: "5", uidOwner: "user2", file: files[1], shareWith: "user1"),
]),
appDb: MockAppDb(),
sqliteDb: util.buildTestDb(),
);
addTearDown(() => c.sqliteDb.close());
await UnshareAlbumWithUser(c)(account,
c.albumMemoryRepo.findAlbumByPath(albumFile.path), "user1".toCi());

BIN
app/web/sqlite3.wasm Normal file

Binary file not shown.